🎸 Django object-metadata the database enforces — not your .save() method.
Most Django soft-delete and timestamp libraries live in Python: a signal here, a
save() override there. It holds up right until a bulk_update, a raw UPDATE,
or a queryset.delete() strolls straight past your code — and leaves the
metadata lying.
django-guitars pushes that work down into PostgreSQL itself — rules and
triggers, not signals. So _created_at / _updated_at / _deleted_at stay
honest no matter how a row gets touched: ORM, bulk, raw SQL, all of it. The
database keeps score; you just write models. Use only the pieces you need.
- Python ≥ 3.10
- Django ≥ 5.0 — uses
db_default - PostgreSQL — currently the only supported backend; the soft-delete rule and
_updated_attrigger live in the database itself. Other backends are on the roadmap.
Status: early days (alpha). The API may still shift between minor versions.
pip install django-guitarsAdd the app to your settings:
INSTALLED_APPS = [
# ...
"guitars",
]The base models are named after string instruments, fewest strings to most — and
the strings are the feature ladder. (du = two, se = three in Persian; tar
= "string". A guitar has six. Django Reinhardt, the jazz guitarist this whole
package winks at, would approve.)
| Base | Strings | What you get |
|---|---|---|
DutarModel |
2 | .update() / .aupdate() and cached-property invalidation on refresh_from_db(). The featherweight — adds no columns. |
SetarModel |
3 | Everything in DutarModel plus DB-managed _created_at / _updated_at (default NOW(); _updated_at is ridden by a statement trigger, so it's right even under bulk/raw updates) and app_label() / model_name() / class_name() helpers. |
GuitarModel |
6 | Everything in SetarModel plus PostgreSQL soft deletion. The full kit. |
Prefer to tune your own chord? Each capability is a standalone mixin in
guitars.models: UpdatableModel, HasCachedPropertyModel, DatedModel, and
SoftDeletableModel.
from django.db import models
from guitars.models import GuitarModel
class Article(GuitarModel):
title = models.CharField(max_length=200)Available on every rung (it comes from DutarModel):
article.update(title="New title") # set fields + save (only changed fields)
article.update(title="x", _save=False) # change in memory only, no DB write
await article.aupdate(title="async") # async variantNote: attributes set with
_save=Falseare not carried into a later_save=Truecall unless you also pass_save_all_fields=True.
For models inheriting SoftDeletableModel (or GuitarModel), .delete() becomes
a soft delete: the row stays and _deleted_at is set. Because a PostgreSQL
rule does the work, it holds even for queryset bulk deletes and raw SQL — there's
no .save() to skip. Three managers expose the data:
Article.objects.all() # live rows only (the default manager)
Article._archives.all() # soft-deleted rows only
Article._all_objects.all() # everything
article.delete() # soft delete — sets _deleted_at
article.is_deleted # True
article.is_alive # False
# Actually want it gone? hard_delete bypasses the rule (and takes CASCADE kids with it):
article.hard_delete() # this row + CASCADE children
Article._all_objects.filter(...).hard_delete() # in bulkSoft-deleting a row also soft-deletes rows related by on_delete=CASCADE.
Subclassing a concrete GuitarModel (Django multi-table inheritance) works: the child gets its own table, but timestamps and soft deletion still resolve to the parent — deleting a child soft-deletes it (child row preserved), a child-only update() still bumps the parent's _updated_at, and hard_delete() clears the whole table chain with no orphaned parent row.
class Ensemble(GuitarModel):
name = models.CharField(max_length=100)
class Orchestra(Ensemble): # own table; metadata lives on Ensemble's
conductor = models.CharField(max_length=100)
class Meta: # required — see below
pass
⚠️ MTI children must declare their ownMeta(an emptyclass Meta: passis enough). Otherwise Django re-declares the parent's_deleted_atpartial index against the child's table, which has no such column, and raisesmodels.E016. Managers are still inherited.
⚠️ Required setup. The soft-delete rule (and the_updated_attrigger) live in a migration generated bymakeguitarmigrations. By defaultmakemigrationsgenerates it for you (see below); until it's created and youmigrate,.delete()permanently deletes the row — the protection isn't wired up yet.
The triggers and rules don't come from plain Django schema migrations — they
live in separate migrations generated by this command, and they're required
for soft deletion and the _updated_at trigger to work.
By default you don't run it directly: makemigrations is extended to generate
these advanced migrations right after the core ones, so a single command keeps
both in sync:
python manage.py makemigrations # generates core + trigger/rule migrationsPrefer the explicit two-command workflow? Turn the extension off and run the command yourself:
# settings.py
GUITARS_AUTO_MAKE_MIGRATIONS = False # defaults to Truepython manage.py makemigrations
python manage.py makeguitarmigrationsEither way, the command scans your first-party apps for models with
_updated_at / _deleted_at and writes the matching trigger/rule migrations.
Tell it which apps are yours:
# settings.py
LOCAL_APPS = ["blog", "shop"] # apps the command scans
# Optional: which app hosts the shared trigger-function migration.
# Defaults to LOCAL_APPS[0].
# TRIGGER_FUNCTION_APP = "blog"Both commands accept optional app labels to scope generation, mirroring
Django's own makemigrations: makemigrations blog (or makeguitarmigrations blog) only touches blog, and an unknown label is rejected the same way
Django's is. With no labels, every app in LOCAL_APPS is scanned.
Cross-app cascade rules: a soft-delete cascade rule (e.g. "deleting a
Bandcascades to itsAlbums") is written into the parent model's migration —Band's app here. If that parent's app isn't named in a scoped run, the cascade rule is skipped even when the child's app is; the command prints a warning naming the skipped rule and the app to include. Run without labels, or name the parent's app, to close the gap.
Use --check in CI to fail when advanced migrations are missing. With the
extension on, makemigrations --check validates both the core and the
trigger/rule migrations; the standalone form still works too:
python manage.py makemigrations --check # checks both layers
python manage.py makeguitarmigrations --check # checks the trigger/rule layer onlyA context manager that temporarily disconnects Django signals — handy for bulk imports or silent saves:
from django.db.models.signals import post_save
from guitars.signals import DisableSignals
with DisableSignals(): # all default signals
instance.save() # nothing fires
with DisableSignals(signals=[post_save]): # only the listed signals
instance.save()Requires uv and Docker (for PostgreSQL).
uv sync # install dependencies + the package (editable)
docker compose up -d # start PostgreSQL (skip if you already run one on :4455)
uv run pytest # run the test suite
uv run pytest --cov=guitars --cov-report=term-missingThe test suite defines concrete models in tests/testapp (the shipped package is
abstract-only) and runs against a real PostgreSQL database, so the rules and
triggers are actually exercised — not mocked.
Two interactive helpers in scripts/ drive a release:
./scripts/bump.sh minor # bump pyproject.toml + seed CHANGELOG, then commit
$EDITOR CHANGELOG.md # write the release notes
./scripts/release.sh # git tag + push + GitHub release (via gh)pyproject.toml is the single source of truth for the version —
guitars.__version__ reads it from the installed package metadata. See
scripts/README.md for details.
MIT © 2026 Behnam RK