🎸 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.
⚠️ Required setup. The soft-delete rule (and the_updated_attrigger) live in a migration generated bymakeguitarmigrations. Until you run that command andmigrate,.delete()permanently deletes the row — the protection isn't wired up yet. Re-run it whenever you add or change a model that uses these bases.
makemigrations does not create the triggers and rules — they live in
separate migrations generated by this command, and it's required for soft
deletion and the _updated_at trigger to work. After your usual makemigrations,
run:
python manage.py makeguitarmigrationsIt 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"Use --check in CI to fail when advanced migrations are missing:
python manage.py makeguitarmigrations --checkA 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 :5432)
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