# Task Cancellation

**URL:** <https://forum.djangoproject.com/t/task-cancellation/46398>\
**Category:** Django Internals\
**Created:** [November 13, 2026, 12:06pm UTC](https://forum.djangoproject.com/t/task-cancellation/46398 "2026-11-13T12:06:56Z")\
**Posts on this page:** 2\
**Page:** 1

<div class="post-metadata">

**Author:** ![rlaager](https://sea2.discourse-cdn.com/flex026/user_avatar/forum.djangoproject.com/rlaager/32/23951_2.png) [@rlaager](https://forum.djangoproject.com/u/rlaager)\
**Post date:** [November 13, 2026, 12:06pm UTC](https://forum.djangoproject.com/t/task-cancellation/46398/1 "2026-11-13T12:06:56Z")

</div>

Currently, there is no supported way to stop a task from running once it has been enqueued. Adding first-class support for this would allow application developers to build UIs for canceling pending tasks (which is my use case that brings me here).

Various task systems support preventing a task from running or stopping one that is running. Properly representing that state, rather than just using `FAILED`, allows application developers to differentiate a task that was stopped from one that crashed.

I propose that Django add to `TaskResultStatus`: `CANCELLED` and `ABORTED`. Both qualify as `TaskResult.is_finished`. Backends set the status, update `TaskResult.finished_at`, and fire the `task_finished` signal when they know the task has stopped. `TaskResult.return_value` would raise `ValueError("Task cancelled")` or `ValueError("Task aborted")`, respectively. `TaskResult.errors` would be empty, unless an exception occurred for some other reason, e.g. as part of aborting the running task.

Backwards-compatibility:

- Existing code that treats `status == FAILED` as the only possible failure will miss the new statuses.
- Existing code that assumes `is_finished and status != SUCCESSFUL` means a crash is fine in practice. It overstates for the new statuses, but overstating is less problematic than understating. How much it overstates is situation specific: A task aborted midway through may be no different than a crash or it may have reverted things in an orderly fashion. A task that was cancelled didn’t crash, but also didn’t do what was asked.
- Existing code that does an exhaustive match (e.g. with `assert_never` or `raise NotImplementedError`) will need to support the new statuses. But that is the expected and desired result of using that pattern.

Allowed state transitions:

- `READY` → `CANCELLED`
- `RUNNING` → `ABORTED`
- `READY` → `ABORTED` (only when the backend cannot tell whether the task had started)

If the backend does not support the more specific states, they could continue to map to `FAILED`.

Spelling: I am proposing `CANCELLED` rather than `CANCELED` because that matches `asyncio.CancelledError` in Python and Procrastinate’s `CANCELLED`.

I think two separate states are warranted for several reasons:

- This maps better to existing task systems.
- `ABORTED` indicates that the task was stopped mid-execution. It could have made incomplete changes.
- `CANCELLED` plus `started_at is not None` is not the same as `ABORTED` if a backend supports retries. If a task runs, fails, is set up to retry (without resetting `started_at`), and then cancelled, it would be in the state `CANCELLED` plus `started_at is not None`, but it was _not_ aborted mid-execution.

I think the state mappings would look like this:

Celery:

- Celery seemingly allows `.revoke()` to either prevent execution of a pending task or send a signal to a running task. Both will end up as `REVOKED`.
  - A CeleryBackend may be able to 1) call `revoke()` and set `TaskResultStatus.CANCELLED`, and 2) call `revoke(terminate=True)` and set `TaskResultStatus.ABORTED`.
  - If it can’t do that and has to naively convert Celery `REVOKED`, that should map to `TaskResultStatus.ABORTED` because it can’t rule out that the task had started.
  - If it implements Celery’s cooperative abort, Celery’s `ABORTED` status only indicates that the request was made; this maps to `TaskResultStatus.RUNNING` until the task has actually stopped. Only then can it become `TaskResultStatus.ABORTED`.

Chancy:

- No separate states, so both scenarios remain using `FAILED`.

django-tasks-db:

- This is what I use, and what brought me here. Adding a `CANCELLED` state is relatively easy.
- It would likely not support a separate abort.

Procrastinate:

- `CANCELLED` → `CANCELLED`
- `ABORTED` → `ABORTED`

RQ:

- `canceled` → `CANCELLED`
- `stopped` → `ABORTED`

I propose the following API methods:

- `BaseTaskBackend.abort(result_id)`: Attempts to stop a task, which may or may not be running. If the task does not exist, `TaskResultDoesNotExist` will be raised by backends that `supports_get_result`. If the task is known to already be `ABORTED` or `CANCELLED`, this method will return successfully. If the backend knows it cannot stop this task, `TaskNotStoppable` is raised. (This includes tasks that have already finished as `SUCCESSFUL` or `FAILED`.) When this method returns, the stop has been requested, but may not be complete. If the backend is eventually able to stop the task, its status will become `CANCELLED` if the backend knows the task never started, otherwise `ABORTED`.
  - Backends that abort tasks synchronously should document that.
  - The default implementation of `BaseTaskBackend.abort()` would be to call `self.cancel()`.

- `BaseTaskBackend.cancel(result_id)`: Attempts to stop a task, only if it has not started running. If the task does not exist, `TaskResultDoesNotExist` will be raised by backends that `supports_get_result`. If the task is known to already be `CANCELLED`, this method will return successfully. If the backend knows it cannot stop this task, `TaskNotStoppable` is raised. (This includes tasks that have already finished as `SUCCESSFUL` or `FAILED`.) When this method returns, the stop has been requested, but may not be complete. If the backend is able to eventually stop the task, its status will become `CANCELLED`.
  - Backends that cancel tasks synchronously should document that.
  - The default implementation of `BaseTaskBackend.cancel()` would be to call `self.get_result(result_id)` (if the backend `supports_get_result`) to handle raising `TaskResultDoesNotExist`. Then it would unconditionally `raise TaskNotStoppable(result_id)`.

- `BaseTaskBackend` would also gain `aabort()` and `acancel()` as async versions of the above.
- `BaseTaskBackend.supports_abort`: Whether the backend supports stopping Tasks which have started running. A backend that `supports_abort` must `supports_cancel` (which could be enforced in `BaseTaskBackend.check()`).
- `BaseTaskBackend.supports_cancel`: Whether the backend supports stopping Tasks which have not started running.
- `TaskResult.abort()` calls `self.task.get_backend().abort(self.id)` and likewise for `aabort()`, `acancel()`, and `cancel()`. Callers must call `refresh()` themselves if they want updated information.

In this API, the caller is indicating how much force it is willing to accept, and the backend can use up to that amount, but can use less. I have not currently differentiated graceful/coordinated stopping (e.g. Celery `abort()`) from forceful termination (e.g. Celery `revoke(terminate=True)`). If that is desirable, I can adjust the proposal to have a `stop()` for graceful termination. Alternatively, that could be added in the future, as `abort()` would remain the forceful termination.

`DummyBackend` should probably `supports_abort` (and `supports_cancel`). It would need to be changed from claiming “Task results will forever remain in the READY state.” to something like “unless aborted or cancelled”.

`ImmediateBackend` should support neither.

I’m using `django-tasks-db` and we implemented cancellation on top of it. Our specific implementation is different, but here is a proposal:

```python
def cancel(self, result_id: str) -> None:
    from .models import DBTaskResult

    try:
        cancelled = DBTaskResult.objects.filter(
            id=result_id,
            status=TaskResultStatus.READY,
        ).update(
            status=TaskResultStatus.CANCELLED,
            finished_at=timezone.now(),
        )
    except ValidationError as e:
        raise TaskResultDoesNotExist(result_id) from e

    if cancelled:
        try:
            task_result = DBTaskResult.objects.get(id=result_id).task_result
        except (DBTaskResult.DoesNotExist, ImportError, SuspiciousOperation):
            logger.exception("Task id=%s cancelled, but its result could not be loaded", result_id)
        else:
            task_finished.send(sender=type(self), task_result=task_result)
        return

    status = (
        DBTaskResult.objects.filter(id=result_id)
        .values_list("status", flat=True)
        .first()
    )
    if status is None:
        raise TaskResultDoesNotExist(result_id)
    if status == TaskResultStatus.CANCELLED:
        return
    raise TaskNotStoppable(result_id)

```

---

<div class="post-metadata">

**Author:** ![CodenameTim](https://sea2.discourse-cdn.com/flex026/user_avatar/forum.djangoproject.com/codenametim/32/31507_2.png) [@CodenameTim](https://forum.djangoproject.com/u/CodenameTim)\
**Post date:** [November 13, 2026, 2:43pm UTC](https://forum.djangoproject.com/t/task-cancellation/46398/2 "2026-11-13T14:43:05Z")

</div>

Hi @rlaager, thanks for sharing this! Do you mind creating an issue on [GitHub - django/new-features: New feature proposals for Django and third-party ecosystem · GitHub](https://github.com/django/new-features/) with these details?
