Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions draft/xxxx-hybrid-signal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
---
DEP: XXXX Hybrid Signals
Author: Mykhailo Havelia
Implementation Team:
Shepherd:
Status: Draft
Type: Feature
Created: 2025-11-06
Last-Modified: 2025-11-06
---

# DEP XXXX: Hybrid Signal

Table of Contents
- [Abstract](#abstract)
- [Motivation](#motivation)
- [Specification](#specification)
- [Rationale](#rationale)
- [Backwards Compatibility](#backwards-compatibility)
- [Copyright](#copyright)

## Abstract

This DEP proposes the introduction of hybrid signal handlers in Django - signal
receivers that can define different implementations for synchronous (`send`) and
asynchronous (`asend`) dispatch. The goal is to eliminate the need for expensive
`sync_to_async` and `async_to_sync` conversions when signals are triggered in mixed
contexts, while keeping backward compatibility with existing Signal usage.

## Motivation

Currently, Django's signal system doesn't distinguish between synchronous and
asynchronous receivers. A signal can connect either sync or async functions, but
internally Django uses thread-based wrappers (`sync_to_async` or `async_to_sync`) when
dispatching to handlers that don’t match the context type.

Example:

```python
from django.core.signals import request_finished
from django.dispatch import receiver

@receiver(request_finished)
def request_logger(sender, **kwargs):
print("Request finished")
```

When this signal is triggered using `asend`, Django wraps sync functions using
`sync_to_async`, spawning a thread for each invocation. This adds unnecessary overhead.
A hybrid signal would allow developers and third-party libraries to register both sync
and async versions of a handler explicitly. This way, Django can directly dispatch to
the appropriate function without any cross-context wrapping or thread spawning.

# Specification

A hybrid signal handler can define both sync and async implementations under a single
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A hybrid signal handler can define both sync and async implementations

Question: What happens if someone doesn't define both? For example, if a HybridSignalHandler only implements sync_call, but is triggered in an async context, does Django automatically wrap it in sync_to_async? Or should it be validated at definition time that you must define both methods?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if someone doesn't define both?

raise an exception

HybridSignalHandler is attempting to remove sync_to_async from the request/response cycle.

registration.

Example API:

```python
class HybridSignalHandler:

def sync_call(self, receiver, **kwargs):
raise NotImplementedError(
"send is not supported for this handler"
)

async def async_call(self, receiver, **kwargs):
raise NotImplementedError(
"asend is not supported for this handler"
)
Comment on lines +62 to +72
Copy link
Member

@tim-schilling tim-schilling Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally, this DEP is a good idea IMO 🚀 I'm a little stuck on this implementation and wanted to see if you had thought of other approaches.

Did you consider revising the @receiver decorator to control whether the handler should be called based on the context? For example:

@receiver(signal, sync=False, _async=True)
async def async_call(receiver, **kwargs):
    ...

@receiver(signal, sync=True, _async=False)
def sync_call(receiver, **kwargs):
    ...

This would avoid requiring people to use a inheritance in their handlers and still provide the flexibility you're looking for. I haven't thought this entirely through though, so I could be missing something.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered that approach, but it's much easier to make mistakes with it. In most cases, a handler should run regardless of whether the context is async or sync. For example:

@receiver(request_finished, with_async=False)
def close_db_connection(sender, **kwargs):
    # close sync DB connection
    ...

When ASGIHandler calls signals.request_finished.asend(sender=self.__class__), Django will not close a database connection that was created inside sync_to_async.

async def index(request):
    # do async work
    ...
    await sync_to_async(do_sync_work_with_sync_db)()

Additionally, even if production uses only async connections, the development server wraps all async views with async_to_sync and still sends request_finished synchronously.

That’s why I think using a class-based design is a better choice. It encourages developers to implement both async and sync versions explicitly, and Django can easily raise an exception if one is missing.

We can skip inheritance and just use duck typing (if an object has sync_call and async_call, then it is a hybrid handler) 😁

I don’t know, I just kind of like the idea of using classes — it feels like a better fit 😅

class LogginHandler:

   def log(self):
       print('done')

    def sync_call(self, receiver, **kwargs):
        self.log()

    async def async_call(self, receiver, **kwargs):
        self.log()

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here it seems like we want the accept a pair of callables, for the sync and async case respectively, rather than just the one.

The old fashioned way to do that might have been to pass a tuple...

receiver(request_finished)((sync_version, async_version))

(Or similar)

Tuples are OK until you need to remember which field stands for what. Is it sync, async or async, sync?

So we have NamedTuples, or these days dataclasses:

from dataclasses import dataclass
from typing import Callable

@dataclass
class MyReceiver:
    sync_handler: Callable
    async_handler: Callable

receiver(request_finished)(MyReceiver(sync_handler=sync_version, async_handler=async_version))

(Or similar)

Here the data class is just a namespace for the pair of callables we want to pass.

If we used a runtime checkable protocol in receiver, folks would be free to define a regular class using bound methods, rather than free functions, if the additional class behaviour (state on self essentially) was of use.

(Or something along those lines)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could leave the receiver decorator untouched and make changes only to the signal API(decorator is only useful for wrapping a function or a class).

request_finished.hybrid_connect(async_handler=async_version, sync_handler=sync_version)

However, I prefer using a class-based approach. In my experience, it's much easier to keep things organized this way, sync and async versions often share common logic, and with a class, it's simple to move that into a method with proper encapsulation.

FieldExtension from the strawberry is a perfect example of how it can look.

Copy link
Member

@carltongibson carltongibson Nov 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, there's no reason to not allow people to use a class, especially if there's shared logic as you say.

My proposal was to define a protocol that would also allow named pairs of receivers (which is conceptually what's needed).

Then you pass either a single callable (as now) or an instance of the protocol (to provide both versions). We already check what kind of receiver was passed at connection, so it's just another branch to handle the both case suggested here.

Does that make sense? Maybe I'm missing something?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense. I just wanted to emphasize that receiver is a decorator that simply calls the connect method under the hood. So, I think we can keep it as it is and just add another method for connecting to the signal.

request_finished.hybrid_connect(async_handler=async_version, sync_handler=sync_version)

or

request_finished.hybrid_connect(handler_obj)
  • we don't need provide dataclass
  • we don't need to change default receiver's behaviour

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. And kwargs might serve the same purpose of keeping which callable is which clear in the code.

(These seem like colour of the bike shed questions. We don't have to settle them exactly at this point 🙂)

```

Usage:

```python
@receiver(request_finished)
class RequestLogger(HybridSignalHandler):
def sync_call(self, sender, **kwargs):
print("Request finished (sync)")

async def async_call(self, sender, **kwargs):
print("Request finished (async)")
```

## Rationale

Django’s current signal system calls receivers the same way in all contexts.
When an async signal is sent, Django wraps sync receivers with `sync_to_async()`,
which runs them in a thread. This works but adds unnecessary overhead for short signals
and makes async code less efficient. The idea of hybrid signals is simple: allow
developers to define both sync and async versions of the same handler. Then Django
can call the right one directly, without using wrappers or threads.

This design:

- avoids extra thread spawning and context switching
- keeps signals simple and backward compatible
- and gives developers clear control over how their handlers run

Similar patterns already exist in other frameworks, for example in `Strawberry GraphQL`,
where extensions can define both `resolve()` and `resolve_async()`.


## Backwards Compatibility

This proposal is fully backward compatible:

- Existing Signal and receiver decorators continue to work as is.
- Libraries can safely migrate incrementally.

Potential risks:

- None for existing code.
- Minimal risk if developers misdefine hybrid handlers (guarded by `NotImplementedError`).

## Copyright

This document has been placed in the public domain per the Creative
Commons CC0 1.0 Universal license
(<http://creativecommons.org/publicdomain/zero/1.0/deed>).

(All DEPs must include this exact copyright statement.)