Skip to content

Migration Guide: 2.x → 3.0

pytaskwarrior 3.0 replaces the default backend with TaskChampionAdapter — direct SQLite access via Rust bindings. The task binary is no longer required.

This guide covers every breaking change and all new features.


Quick summary

What changed Before (2.x) After (3.0)
Default backend TaskWarriorAdapter (CLI) TaskChampionAdapter (SQLite)
task binary required yes no (only for CLI mode)
TaskWarrior() spawns task reads/writes SQLite directly
task_cmd default "task" None
task_cmd positional TaskWarrior("task") TaskWarrior(task_cmd="task")
get_info()["task_cmd"] always str None when TC adapter
get_info()["backend_type"] key didn't exist "taskchampion" or "taskwarrior-cli"
get_info()["version"] CLI version string key removed → use backend_version
ContextService(adapter, cfg) positional order ContextService(cfg)
UdaService(adapter, cfg) positional order UdaService(cfg)
tw.context_service property public removed → use facade methods
tw.uda_service property public removed → use facade methods
taskwarrior.version alias module export removed → use __version__
task sync (CLI) default sync path only when task_cmd="task"
sync.server.url key accepted removed → use sync.server.origin
sync.client.id key accepted removed → use sync.server.client_id
Urgency score returned by CLI None (not computed)
OR / AND filters supported by CLI not supported by TC adapter

Breaking changes

1. Default backend is now TaskChampionAdapter

TaskWarrior() with no arguments now creates a TaskChampionAdapter. No task binary is spawned.

# 2.x
tw = TaskWarrior()           # spawned `task` subprocess, required task binary

# 3.0
tw = TaskWarrior()           # reads/writes SQLite directly, no binary needed
tw = TaskWarrior(task_cmd="task")  # explicit CLI mode (unchanged behaviour)

Action required if you relied on the CLI adapter being the default: add task_cmd="task" to your TaskWarrior() call.

2. task_cmd was the first positional argument

task_cmd changed from str = "task" to str | None = None. If you passed the binary path positionally, add the keyword:

# 2.x — positional, worked because str default
TaskWarrior("/usr/bin/task")

# 3.0 — must be explicit keyword
TaskWarrior(task_cmd="/usr/bin/task")

These forms are unchanged:

TaskWarrior(task_cmd="task")      # ✅ still works
TaskWarrior(taskrc_file="...")    # ✅ still works
TaskWarrior(data_location="...")  # ✅ still works
TaskWarrior(adapter=my_adapter)   # ✅ still works

3. get_info() shape change

The "version" key has been removed from the dict. info["task_cmd"] and info["options"] are None when the TC adapter is active (the default). A new "backend_type" key identifies the active backend.

# 2.x — always had values
info = tw.get_info()
print(info["task_cmd"])   # "/usr/bin/task"
print(info["version"])    # "3.4.0"

# 3.0
info = tw.get_info()
print(info["backend_type"])     # "taskchampion"  or  "taskwarrior-cli"
print(info["backend_version"])  # "taskchampion-py/3.0.1"  or  "3.4.0"
print(info["task_cmd"])         # None  (TC)  or  "/usr/bin/task"  (CLI)
# info["version"]  →  KeyError — key removed entirely

Action required: Replace info["version"] with info["backend_version"]. Guard info["task_cmd"] with if info["task_cmd"]: ....

4. ContextService.__init__ argument order

The adapter parameter has been removed. This affects code that instantiates ContextService directly (rare). Use TaskWarrior facade methods for all context operations.

# 2.x
from taskwarrior.services.context_service import ContextService
svc = ContextService(adapter, config_store)

# 3.0
svc = ContextService(config_store)

5. UdaService.__init__ argument order

Same change as ContextService:

# 2.x
from taskwarrior.services.uda_service import UdaService
svc = UdaService(adapter, config_store)

# 3.0
svc = UdaService(config_store)

6. TaskWarrior.context_service and .uda_service properties removed

These properties no longer exist on TaskWarrior. Use the facade methods directly:

Before After
tw.context_service.define_context(ctx) tw.define_context(ctx)
tw.context_service.get_contexts() tw.get_contexts()
tw.context_service.apply_context(name) tw.apply_context(name)
tw.context_service.get_current_context() tw.get_current_context()
tw.context_service.has_context(name) tw.has_context(name)
tw.context_service.delete_context(name) tw.delete_context(name)
tw.context_service.unset_context() tw.unset_context()
tw.uda_service.define_uda(uda) tw.define_uda(uda)
tw.uda_service.update_uda(uda) tw.define_uda(uda)
tw.uda_service.delete_uda(uda) tw.delete_uda(uda)
tw.uda_service.get_udas() tw.get_udas()
tw.uda_service.get_uda_names() tw.get_uda_names()

Action required: replace all tw.context_service.* and tw.uda_service.* calls with the corresponding tw.* facade methods.

7. taskwarrior.version alias removed

# 2.x
from taskwarrior import version   # module-level alias

# 3.0 — use the standard dunder
from taskwarrior import __version__

8. Sync .taskrc key names

pytaskwarrior 3.0 aligns with the TaskWarrior 3.x standard key names. Update your .taskrc if needed:

Old key New key
sync.server.url sync.server.origin
sync.client.id sync.server.client_id
sync.encryption.secret unchanged
sync.local.server_dir new (local sync)
# .taskrc — 3.0 format
sync.server.origin=https://taskchampion.example.com
sync.server.client_id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
sync.encryption.secret=my-passphrase

7. Urgency is None

TaskOutputDTO.urgency is always None when using the default TC adapter. TaskChampion does not compute urgency scores.

# 2.x — urgency computed by `task`
task = tw.get_task(1)
print(task.urgency)   # 8.2

# 3.0 — urgency not computed
task = tw.get_task(1)
print(task.urgency)   # None

Action required: if you sort or filter by urgency, switch to CLI mode or implement urgency computation in your application layer.

8. get_tags(include_virtual_tags=True) return value

In 2.x with the CLI adapter, get_tags(include_virtual_tags=True) returned TaskChampion's internal synthetic tag strings (e.g. "Synthetic(Pending)"). In 3.0 it returns the standard TaskWarrior virtual tag names:

# 2.x (TC adapter, pre-3.0)
tw.get_tags(include_virtual_tags=True)
# ["work", "Synthetic(Pending)", "Synthetic(Unblocked)", ...]

# 3.0
tw.get_tags(include_virtual_tags=True)
# ["work", "PENDING", "UNBLOCKED", "OVERDUE", "DUE", ...]

New features

Filters: date-range expressions

The TC filter engine now understands date-range tokens — no CLI required:

tw.get_tasks("due.before:tomorrow")
tw.get_tasks("due.after:eom")
tw.get_tasks("due.by:friday")          # inclusive (≤)
tw.get_tasks("scheduled.after:today")
tw.get_tasks("wait.before:now")
tw.get_tasks("due.before:now + P7D")   # compound expression

Supported fields: due, wait, scheduled, until, entry, modified

Supported operators:

Operator Meaning
before strict <
after strict >
by (inclusive)
not — tasks with no date set also match

Filters: virtual tags in pure Python

All 30 TaskWarrior virtual tags work as filter tokens without calling the CLI. 28 are computed in Python; LATEST is a post-filter selector; ORPHAN is recognized but always returns False:

tw.get_tasks("+OVERDUE")
tw.get_tasks("+READY -BLOCKED")
tw.get_tasks("+DUE +PRIORITY project:work")
tw.get_tasks("+WEEK")
tw.get_tasks("+LATEST")   # keeps only the most recently created task

See TaskChampion Adapter — Virtual Tags for the full table.

Date expressions: compound forms with spaces

DateResolver (used by task_calc() and all filter date thresholds) now resolves compound expressions containing a space-separated operator:

tw.task_calc("now + P1D")      # → tomorrow same time (ISO 8601)
tw.task_calc("today + 3d")     # → 3 days from now at midnight
tw.task_calc("eom - P1W")      # → one week before end of month
tw.task_calc("now + 2weeks")   # → TaskWarrior shorthand form

# Combined with filters
tw.get_tasks("due.before:now + P7D")
tw.get_tasks("scheduled.after:eom - P1W")

.taskrc writes without the CLI

ConfigStore now supports direct writes. ContextService and UdaService use it for all write operations — the task binary is never called for configuration changes:

# These all work without a task binary in 3.0:
tw.define_uda(UdaConfig(name="complexity", uda_type=UdaType.STRING, label="Complexity"))
tw.delete_uda(UdaConfig(name="complexity", uda_type=UdaType.STRING))
tw.define_context(ContextDTO(name="work", read_filter="project:work", write_filter="project:work"))
tw.apply_context("work")
tw.unset_context()
tw.delete_context("work")

Low-level access is also available:

tw.config_store.set_value("uda.complexity.type", "string")
tw.config_store.set_value("uda.complexity.label", "Complexity")
tw.config_store.delete_value("uda.complexity.type")

Sync: local directory mode

In addition to remote HTTP sync, the TC adapter now supports local directory sync — useful for syncing between machines via a network share, or for testing without a server:

# .taskrc
# sync.local.server_dir=/mnt/share/taskserver

tw = TaskWarrior()   # picks up sync.local.server_dir automatically
tw.synchronize()

Or directly:

adapter = TaskChampionAdapter(sync_local_server_dir="/mnt/share/taskserver")
adapter.synchronize()

Sync: stable client_id auto-generated

When remote sync is configured but sync.server.client_id is absent, a UUID is generated and written to .taskrc automatically. The same ID is reused in all subsequent sessions, preventing duplicate client registrations.

In-memory adapter for tests

TaskChampionAdapter supports an in-memory database — no filesystem needed:

from taskwarrior.adapters.taskchampion_adapter import TaskChampionAdapter
from taskwarrior import TaskWarrior

tw = TaskWarrior(adapter=TaskChampionAdapter(data_location=None))
# Fully isolated, nothing written to disk

apply_filter(now=) for deterministic tests

The public filter function accepts a now argument for time-sensitive tests:

from datetime import datetime, timezone
from taskwarrior.adapters.tc_filter import apply_filter

NOW = datetime(2026, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
results = apply_filter(tasks, "+OVERDUE", now=NOW)

Limitations of the TC adapter vs CLI adapter

Feature TC adapter (default) CLI adapter (task_cmd="task")
CRUD (add / modify / done / delete)
Date-range filters
Virtual tags ✅ (28 computed, 1 selector, 1 stub)
task binary required
Urgency score ❌ always None
OR / AND filters
Parenthesised filter expressions
Remote sync (TC protocol) sync_to_remote task sync
Local directory sync sync_to_local task sync
Context & UDA management ✅ (.taskrc writes)

For features in the ❌ column, use TaskWarrior(task_cmd="task").


Migration checklist

# ✅ 1. Update the constructor call
tw = TaskWarrior(task_cmd="task")    # keep CLI adapter, or:
tw = TaskWarrior()                   # switch to TC adapter (no binary)

# ✅ 2. Fix positional task_cmd usage
# Before: TaskWarrior("/usr/bin/task")
TaskWarrior(task_cmd="/usr/bin/task")

# ✅ 3. Replace info["version"] with info["backend_version"]
info = tw.get_info()
print(info["backend_version"])        # always present
if info["task_cmd"]:                  # guard before use
    print(info["task_cmd"])

# ✅ 4. Update sync keys in .taskrc
# sync.server.url   → sync.server.origin
# sync.client.id    → sync.server.client_id

# ✅ 5. Remove urgency-dependent logic (TC adapter) or switch to CLI
task = tw.get_task(1)
if task.urgency is not None:   # guard
    ...

# ✅ 6. Update ContextService / UdaService if used directly
# ContextService(config_store)   # adapter removed
# UdaService(config_store)       # adapter removed

# ✅ 7. Replace tw.context_service / tw.uda_service with facade methods
# tw.context_service.define_context(ctx)  →  tw.define_context(ctx)
# tw.uda_service.define_uda(uda)          →  tw.define_uda(uda)
# (see section 6 for full mapping)

# ✅ 8. Replace taskwarrior.version with __version__
from taskwarrior import __version__