django-pgware is the successor to django-pglocks, and it absorbs two siblings on the way: django-pg-set (temporarily setting GUCs) and pg-hush-django (keeping sensitive query parameters out of the logs). Three small single-purpose packages I’d maintained separately are now one distribution, one version, one test matrix. The import root stays django_pg_utils, so existing call sites change a prefix and nothing else.
The three tools have almost nothing in common as features. What they share is a shape: borrow Django’s connection, do a PostgreSQL action scoped to the current session, and undo it reliably when the scope ends. Advisory locks, SET without LOCAL, and the logging GUCs are all session-scoped; pg_set and atomic_set are the plainest version of the idea, setting work_mem for the length of a query or a transaction and putting it back on the way out. The features are trivial PostgreSQL. The engineering is entirely in the word guarantee: under async, under thread concurrency, under partial failure. Consolidating was a good excuse to go find the places that word had been quietly lying.
Async advisory locks, and the trap underneath them
The headline new capability is async_advisory_lock; django-pglocks was sync-only. It isn’t one async def away, because of how the PostgreSQL feature works: a lock taken on one connection can only be released on that same connection. In async Django, synchronous DB work goes to a thread pool via sync_to_async, and nothing guarantees two calls hit the same thread, hence the same connection, hence the same session. Acquire on one session, release on another, and the release silently does nothing. The lock leaks, with no error.
The fix rides on a default. sync_to_async defaults to thread_sensitive=True, which pins all thread-sensitive work from a coroutine onto one shared worker thread; that thread owns a single Django connection, so acquire, your code, and release all land on the same session. It works, and it is invisible at the call site, which is the danger: set that default to thread_sensitive=False some day and the code still compiles, still passes a casual test, and starts leaking locks under real timing. The trap is documented in the source so the next person doesn’t walk into it.
Keeping secrets out of the log, without a race
hush silences PostgreSQL server logging and Django’s query logging inside a scope, so cursor.execute("SELECT decrypt_card(%s)", [token]) doesn’t write the cardholder data to a log file. The PostgreSQL half is per-connection, and Django connections are thread-local, so two threads suppressing at once don’t collide. Django’s django.db.backends logger is the problem: one object for the whole process. Save-the-level-and-restore-it races badly. Two hush scopes overlap, the second saves the already-suppressed level, and whoever exits last wins; you either restore logging while another scope still needs it quiet (your secrets, in the log) or you pin the logger at CRITICAL forever. The 1.0.x line reference-counts it: capture the original level only on the first entry, restore only on the last exit, under a lock. It’s the one spot where shared process-global state forced a real counter instead of per-caller bookkeeping.
What else the review turned up
The merge was a fresh-eyes pass, and it surfaced more than those two races. GUC names and lock-function names get interpolated into SQL text, not bound as parameters (you can’t bind an identifier), so a user-derived name was a live SQL-injection path; names are now validated against a whitelist (VALID_LOCK_FUNCTIONS) and a regex (validate_guc_name) before they reach a cursor. The bare with hush: form kept its state on a shared singleton, which under nesting could drop a live scope and let Python garbage-collect it mid-scope, running the restore whenever the GC felt like it; it now uses a per-thread stack. And lock release and hush restore both run best-effort in finally: if cleanup fails it warns instead of raising, so a failed unlock can’t bury the exception you were already throwing.
One behavior change to know about
hush() used to suppress on every configured connection; it now targets only the default one. Pass database="*" for the old fan-out. The reason: suppressing everywhere meant one call on the payment path would dial your replicas and analytics databases as a side effect. If you’re coming from pg-hush-django, that’s the line to check.
pip install django-pgware, plus a driver; like Django, it takes psycopg2 or psycopg (v3), and CI runs both across Python 3.10, 3.12, and 3.13, Django 4.2, 5.2, and 6.0, and PostgreSQL 14 through 18. Beta, stable public API, PostgreSQL License.