A custom Django system check that queries a shop table breaks migrate on a fresh database and every command in CI; what is wrong and how do you fix it?
answer
- checks are supposed to be static
- they run before the migrations do
- untagged checks run almost everywhere
- Tags.database and the databases kwarg
- Warning, not Error, for fixable-by-migrate
basics
~20 sAn untagged check runs before nearly every command, so its query fails wherever no database or table exists, and migrate runs checks before migrating. Keep data invariants out of checks; tag real database checks with Tags.database and use only the aliases passed in.
solid answer
~50 sSystem checks are meant to be static, and an untagged check runs before `makemigrations`, `runserver`, custom commands and the test runner, so a query inside it needs a reachable database with the table already created. On a fresh database, `migrate` runs the checks — including `database`-tagged ones for its alias — **before** applying any migration, so the table is not there yet, and an exception in a check crashes the command rather than becoming a message. Fix it by moving data invariants out of checks (into constraints, a data migration or a health check), and keeping checks that truly need a connection to database **configuration**: register them with `Tags.database`, loop over the `databases` aliases they are given, catch `DatabaseError` and turn it into a message, and use `Warning` for anything a pending migration will fix, so the check cannot block the migration that repairs it.
code
python · 30 lines# shop/checks.py
from django.core.checks import Tags, Warning, register
from django.db import DatabaseError, connections
@register(Tags.database)
def check_trigram_extension(app_configs, databases=None, **kwargs):
messages = []
for alias in databases or []:
connection = connections[alias]
if connection.vendor != "postgresql":
continue
try:
with connection.cursor() as cursor:
cursor.execute("SELECT 1 FROM pg_extension WHERE extname = 'pg_trgm'")
installed = cursor.fetchone() is not None
except DatabaseError as exc:
messages.append(
Warning(f"Could not inspect extensions on {alias!r}: {exc}", id="shop.W002")
)
continue
if not installed:
messages.append(
Warning(
f"pg_trgm is not installed on {alias!r}; coupon search is slow.",
hint="Apply the migration that runs TrigramExtension().",
id="shop.W003",
)
)
return messagesgo deeper
Recall that system checks should be static and run before most commands, so they must not depend on data being in the database.
Explain Tags.database, the databases keyword argument, migrate passing its alias, and why an exception in a check crashes the command.
Diagnose the bootstrap failure, move data invariants to migrations or constraints, and write tolerant configuration checks that warn rather than block the fixing migration.
Set a rule for what may live in the check registry versus migrations and runtime health checks, so tooling never depends on environment state it cannot guarantee.
## What the check did wrong The failing check probably looked like this: an untagged `@register()` function that runs `Coupon.objects.filter(code="WELCOME").exists()` and returns a warning if the row is missing. Three facts about the framework turn that into an outage of the tooling: - **Checks are static by design.** The framework validates code and configuration; the docs single out database checks as the exception because they "do more than static code analysis". - **Untagged checks run almost everywhere.** `BaseCommand.execute()` runs every registered, non-database check before most commands. `makemigrations` in a CI job without a database, `runserver` on a fresh laptop and a custom command all execute the query. - **An exception is not a message.** The registry calls each check without catching errors, so an `OperationalError` or `ProgrammingError` escapes and the command dies with a traceback. ## Why migrate fails on a fresh database `migrate` overrides `get_check_kwargs()` to pass its `--database` alias, so it runs the regular checks **and** the `database`-tagged checks, and it does so in `execute()` **before `handle()`** applies anything. On an empty database the `shop_coupon` table does not exist yet, so the query fails before the migration that would create it can run. The project cannot be bootstrapped by the very command meant to bootstrap it. ## The fix, in order 1. **Ask whether it is a check at all.** "The WELCOME coupon exists" is a **data invariant**. It belongs in a data migration that creates the row, a database constraint, a management command, or a runtime health check — not in the check registry. 2. **Keep database checks to configuration.** Legitimate examples: the server version, a required extension, a connection option. Register them with **`Tags.database`**, which keeps them out of ordinary commands; they run only under `migrate` and `check --database <alias>` (and in the test runner, against the test databases). 3. **Use the aliases you are given.** Accept `databases=None` and loop over it; skip aliases whose `connection.vendor` does not apply. 4. **Catch database errors inside the check** and return a `Warning` that says the check could not run, rather than crashing the command. 5. **Choose `Warning` for anything a migration fixes.** Because `migrate` runs checks first, an `Error` about a missing extension would block the migration that creates it. ## A version detail to know The topic guide still says a check must not use connections when `databases` is `None`. In **Django 6.1** the registry fills in every configured alias when a caller passes none, and the release notes tell callers to be prepared for databases to be accessed. So a `databases is None` guard no longer keeps a check off the database; the **`Tags.database` tag** is what keeps it out of ordinary commands. ## Comparing the options | Approach | Runs in CI without a DB | Survives a fresh `migrate` | Right for | |---|---|---|---| | Untagged check with an ORM query | No | No | Nothing | | `Tags.database` check querying app tables | Yes | No | Nothing | | `Tags.database` check on server configuration, `Warning` on failure | Yes | Yes | Extensions, versions, options | | Data migration or constraint | Not applicable | Yes | Required rows and invariants | | Runtime health check | Not applicable | Yes | Reachability of dependencies | ## Diagnosing it quickly 1. Run `manage.py check --list-tags` to confirm your tag is registered, then `manage.py check --tag <yourtag>` to run only your checks and reproduce the failure in isolation. 2. Read the traceback: a database exception raised from inside a function in `checks.py` means the check itself touched the database. 3. Search the project for `@register` functions that import models or open connections. 4. Reproduce the bootstrap with an empty database: `migrate` failing before any "Applying …" line confirms the check runs ahead of the migrations. 5. As a stop-gap, `migrate --skip-checks` lets the database be built while the check is rewritten — then remove the need for it. ## Cost matters too `runserver` reruns every non-database check after each autoreload, and every management command pays for them. A check that opens connections or does I/O makes the whole development loop slower. Keep checks cheap, deterministic and free of side effects.
- Why use Warning rather than Error for the missing-extension check above?`migrate` runs the database checks for its alias before applying migrations. If the extension is created by a pending migration, an `Error` would raise `SystemCheckError` and stop `migrate` from ever applying the migration that fixes it. A `Warning` reports the gap and lets the fix run; `check --database default --fail-level WARNING` can still gate a release on it.
- Where should a 'payment provider is reachable' test live instead of a system check?In a runtime readiness or health check that the platform polls after the process starts, because reachability changes over time and depends on the network. A system check runs before commands, in CI and on laptops, so a network call there makes every command slow and fails in environments that are not supposed to reach the provider.
saying these in an interview costs you the question
- System checks run only under runserver, so queries in them are harmless
- migrate applies migrations first and runs the checks afterwards
- An exception raised in a check is reported as an Error message
- Checking databases is None still keeps a check off the database in 6.1
- Data invariants such as required rows belong in system checks