Skip to content

0009. One database alias per app

  • Status: Proposed
  • Date: 2026-11-03, amended 2026-11-12

0004 forbids cross-app transactions, even while attached. Enforcing that by inspecting code is hard. As first proposed, this record chose one alias per app with a router. That left three things unsaid that roadmap step 4 (#11) needs before its router (PR 6 of that plan) and its host settings (PR 7) can be written:

  • What default is. Django requires the key: ConnectionHandler.configure_settings raises “You must define a ‘default’ database.” without it, and turns {} into the dummy backend (db/utils.py). Django’s documentation describes the blank form: “the parameters dictionary can be left blank if it will not be used. To do this, you must set up DATABASE_ROUTERS for all of your apps’ models, including those in any contrib and third-party apps” (multiple databases). Every database API that takes no alias falls back to default: transaction.atomic(), on_commit() (db/transaction.py, get_connection), django.db.connection (db/__init__.py), a router list that gives no answer (db/utils.py, ConnectionRouter._router_func), the --database option of the management commands and TestCase.databases (test/testcases.py).
  • What an alias is called, and who chooses the name.
  • What the deployer may say about a database, given that some Django settings join aliases back together.

Two other Proposed records of step 4 are named here by number: the app declaration record (0021), which sets the form of a declaration, and the app host record (0022), which sets what the host builds and checks. “PR 6” and “PR 7” are the pull requests of #11’s plan.

The three kinds of installed app:

  • a hosted app with a database: it has a declaration (0021) that lists the database resource;
  • a hosted app without one: it has a declaration, no models and no database resource;
  • an undeclared app: a plain Django app with no declaration, such as django.contrib.auth or an existing project’s own apps before they adopt neolith.

A real default is a default alias with connection settings, as opposed to the blank default: {}. A closed host is a host with no real default (0022 defines the term the same way). Every installed app with models in it has a declaration, since there is nowhere else for tables to live. Every neolith run host in step 4 is closed.

Django was read and run at 5.2.19 and 6.1.3. Paths are relative to the django package, and functions are cited by name, since line numbers differ between the two versions. Each fact below was checked by running both versions with two apps on two SQLite files, unless a note says it was only read.

  1. With default: {}, a bare transaction.atomic(), connection.cursor(), on_commit() and migrate each raise ImproperlyConfigured from db/backends/dummy/base.py (complain). The text is Django’s: “settings.DATABASES is improperly configured. Please supply the ENGINE value.”
  2. transaction.atomic(using="<a>") around writes of apps <a> and <b> rolls back <a>’s write only; <b>’s write stays. This is the behaviour two detached apps have.
  3. migrate --database=<b> with no app label writes a django_migrations row for every installed app into <b>’s database, though the router lets it create only <b>’s tables. migrate <a> --database=<a> writes <a>‘s rows only. MigrationExecutor.migration_plan(graph.leaf_nodes()) on an alias then reports other apps’ migrations as pending; leaf_nodes("<label>") does not. migrate takes at most one app label.
  4. The test runner gives each alias other than default a dependency on default unless TEST["DEPENDENCIES"] says otherwise (test/utils.py, get_unique_databases_and_mirrors; the default of ['default'] is documented). When a test lists only app aliases, default is not among the databases to set up, and the run stops with “Circular dependency in TEST[DEPENDENCIES]”. With TEST["DEPENDENCIES"] = [] on each app alias it passes.
  5. Two aliases with the same test signature, (HOST, PORT, ENGINE, test NAME) (db/backends/base/creation.py, test_db_signature), are created and migrated once, for the first alias only (test/utils.py, setup_databases). The second app’s tables are then missing: “no such table”. The signature does not read OPTIONS.
  6. BaseHandler.make_view_atomic wraps every sync view in a transaction on every alias whose ATOMIC_REQUESTS is true, whichever app the view belongs to, and raises RuntimeError: You cannot use ATOMIC_REQUESTS with async views. for any async view when any alias has the setting (core/handlers/base.py; read, not run).
  7. Django checks an app label only with str.isidentifier() (apps/config.py, AppConfig.__init__), which admits upper case and non-ASCII letters. The router API is keyed by that label: model._meta.app_label and allow_migrate(db, app_label) (db/utils.py). A project can give an installed app another label by subclassing its config; the app’s table names follow the new label.
  8. Django reads DATABASES once, at the first model class, and DATABASE_ROUTERS at the first routing question. ConnectionHandler.settings is cached on first use, and the first use is the first model class: Options.contribute_to_class and the many-to-many table name (db/models/options.py, db/models/fields/related.py) call connection.ops.max_name_length(). After django.setup() with only default: {}, writing a settings.DATABASES with one more alias leaves list(connections) == ['default'], and the new alias raises ConnectionDoesNotExist. ConnectionRouter.routers is cached on its own first use, which a model class does not cause: after django.setup() it was not yet cached, and a DATABASE_ROUTERS written then was the one Django used. Django also rebuilds the router list when override_settings changes that setting (test/signals.py).
  9. A default table name is cut to the limit of default’s backend, not of the app’s own alias (same two call sites). With an 80-character default name, the name stays whole under default: {}, whose limit is None, and becomes a 63-character name with a hash suffix under a default whose limit is 63 (PostgreSQL’s value; the run patched SQLite’s limit to 63, no PostgreSQL server was used). A db_table that is set is never cut.
  10. Labels reach the router that belong to no installed app. The database cache backend routes under django_cache (core/cache/backends/db.py), and createcachetable asks allow_migrate_model for it. With default: {} and a router that sends unknown labels to default, cache.get() raises the dummy backend’s message.
  11. With contenttypes and auth on a real default, rows about hosted apps’ models are written there. A plain migrate left 3 ContentType and 12 Permission rows for two hosted apps’ models in default (create_contenttypes runs on post_migrate for every app and asks only whether ContentType may migrate on that alias). It also recorded both hosted apps’ migrations in default’s django_migrations.
  12. The test runner migrates every alias unfiltered, with run_syncdb=True (db/backends/base/creation.py, create_test_db). Every test database carries a django_migrations row for every app, and the tables of its own app only.
  13. SQLite holds a database in memory under several names, and the name does not always say so. Two aliases named file:<x>?mode=memory&cache=shared share one database: the second read the table the first created, although is_in_memory_db is true for both and their test signatures differ. With :memory:, each connection has its own database, and connections are per thread: a second thread got no such table after the main thread had migrated. SQLite’s other shared form, file::memory:?cache=shared, is in memory too, and Django’s is_in_memory_db is false for it: another thread saw its rows, the table was gone after the last connection closed, and no file was made. The memdb VFS (file:/<x>?vfs=memdb) is shared the same way. file: with an empty path (SQLite’s private temporary database) and Path(":memory:") behave like :memory:: another thread got no such table, and is_in_memory_db is false for both. So three of the six forms are one database per connection, and three are shared by every connection that writes the name the same way, a second alias included. For each of the six, and for none of the file names tried, PRAGMA database_list gives an empty file for main. A file is one database however it is named: Django opens SQLite with uri=True, and an alias named by a plain path, one named file:<that path>, one with a query string added and one with the path percent-encoded all read the table the first created.
  14. MigrationRecorder.applied_migrations only reads. On a SQLite file that does not exist it returns {}, and opening the connection creates an empty file.
  15. Django refuses two installed apps with one label at apps.populate: “Application labels aren’t unique, duplicates: <label>”.

Two principles pull against each other here. Principle 5 (same semantics over same mechanics) asks that app code meet the same database behaviour in every host. Principle 9 (new and existing projects alike) asks that a running project, whose tables and unaltered code live on default, can adopt one app at a time.

  1. A shared default alias with a lint rule. It costs nothing at runtime. Enforcement is by review and is incomplete.
  2. One alias per app, set by the app host, with default left for apps that have no declaration. Attached apps then cannot share a transaction, as detached apps cannot. Every app needs its own database, and app code names its alias where Django would use default.
  3. One transaction shared by the attached apps, the usual form in modular monoliths. It gives single-commit consistency while attached, which would not survive a detach.

Option 2, in the eight decisions below. Options 1 and 3 are weighed under Decision 1.

Every input below has one form. Anything else stops the host before Serve (APP-1, K2), with a message that names the app or alias and the input. The last column says when the check can run: before means from the declarations and the deployer’s mapping alone, before Django imports a model (Decision 8); after means once the app registry is ready.

Input Admitted Refused, with its message Checked
The alias of a hosted app The app’s label, unchanged. It is not an input. No way exists to state another name. before
The label of a hosted app The label its own declaration gives it, in the grammar 0021 sets for an app’s name (segment of spec 01, CON-10) and checks when the declaring class is defined. This record adds one rule: it is not a label the host reserves. Those are default and the labels Django routes without an installed app, which today is django_cache alone (fact 10). A project-level relabel is not expressible (0021). app <label>: "<label>" is reserved for the host. before
A hosted app’s database resource Declared, or not declared. At most one. 0022 adds that an app that declares it has migrations. app <label>: has models but declares no database resource. after
The deployer’s database input One mapping, alias to connection settings. Its keys are exactly the labels of the hosted apps with a database, plus default when the deployer wants a real one (Decision 3). database <alias>: no hosted app declares it; app <label>: no connection settings for its database. before
default in that mapping Present exactly when something in the host lives on it: an undeclared app with models, or a database cache. database default: nothing in this host lives on it; app <label>: has models and no declaration, and this host has no default database. after
Connection settings of one alias Django’s own mapping for DATABASES[alias], passed on unchanged, with an ENGINE that is set and is not the dummy backend, and a NAME that is not empty. database <alias>: ENGINE is required; database <alias>: NAME is required; a service or option file alone is not admitted yet. before
ATOMIC_REQUESTS on an app alias Absent. database <alias>: ATOMIC_REQUESTS would wrap other apps' views in this app's transaction. before
TEST["MIRROR"], any alias Absent. database <alias>: TEST.MIRROR joins two aliases into one connection in tests. before
TEST["DEPENDENCIES"] on an app alias Absent; the host writes []. database <alias>: TEST.DEPENDENCIES is set by the host. before
TEST["NAME"], any alias Absent. Django then names each alias’s test database from its NAME, or from the alias on SQLite, so no two aliases share one. database <alias>: TEST.NAME is not admitted yet; Django names the test database from NAME. before
Two aliases on one database Never: the comparison key of Decision 5 differs for every pair. With TEST["NAME"] absent, so does the test database Django gives each. Only the literal SQLite name :memory:, as a str or a Path, is exempt from the comparison; 0022 refuses every in-memory database in a host that serves. databases <a> and <b>: both name <engine> <name> on <host>:<port>. before
A database cache (DatabaseCache or a subclass in CACHES) Only in a host with a real default, where its table lives. cache <alias>: a database cache needs a default database, and this host has none. after
Table names of a hosted app Each model’s and auto-created many-to-many table’s name is either set (db_table) or the whole default name, never one that default’s backend shortened. app <label>: the table name of <model> was shortened to fit the default database; set db_table. after
Migrations of a hosted app No dependency on a migration of another label. app <label>: migration <name> depends on <other label> (0004). after, at Boot
DATABASES, DATABASE_ROUTERS in the deployer’s input Absent. They are host output. 0022’s message for a setting the host builds. before

The name of the deployer’s mapping (NEOLITH["DATABASES"]), the checks’ identifiers and the builder that runs the rows marked before belong to 0022. The form of the resources field belongs to 0021. This record needs only what the table says of them.

  1. One alias per app, set by the host, with one router. This is the record’s decision as first proposed. The host gives every hosted app with a database its own alias, and a router pins the app’s models to it. Django opens a connection per alias and transaction.atomic() is per alias, so two attached apps cannot share a transaction (fact 2).

    • Weighed: a shared default alias with a lint rule. It costs nothing at runtime, but enforcement is by review and is incomplete.
    • Weighed: one transaction shared by attached modules, the usual form in modular monoliths. Spring Modulith describes its trade-off: synchronous events give a simple either-all-or-none model and widen the transaction boundary as listeners are added, and its event publication registry writes in the publisher’s transaction (events). What this record gives up by not taking that form is single-commit consistency across apps while they are attached. The reason is Accepted 0004: that consistency would not survive a detach. Dapr sits on the same side, scoping state by app id by default (state sharing).
  2. The alias is the app’s label, and no input names it.

    • Why: the router receives labels and nothing else (fact 7), so label-to-alias needs no table and cannot drift from the declaration. One name also serves using=, the --database option, TestCase.databases and the deployer’s mapping.
    • The label is a host-side fact in Django’s own model, since a project may relabel an installed app (fact 7; AppConfig.label). The claim that app code “knows its own label” therefore holds only because a hosted app’s label is the one its own declaration gives, and 0021 admits no relabel (the table’s second row). Django already binds an app to its label in three places an app author cannot ignore: migration records, ContentType.app_label and default table names. The alias adds a fourth of the same kind.
    • Weighed: a name declared by the app, as the roadmap sketch resources = {"database": "<name>"} suggests, and as Encore does with a database name separate from the service name (databases). It lets two facts disagree and adds a collision case (two apps, one name) for no gain seen so far. A host-chosen prefix such as neolith_<label> avoids clashing with a deployer’s own aliases, but Decision 6 leaves the deployer no aliases of their own, and the prefix would appear in every using=. A lookup that hands app code its alias would remove the repeated literal; it adds an API for a value that cannot differ from the label, so it is left out for now.
    • Grammar: 0021 sets it: the label matches spec 01’s segment, the rule already used for contract names. This record adds only that it is not a reserved label, default or django_cache, for every hosted app, with or without a database, so that the rule does not depend on a second field. django_cache is reserved because the router is keyed by label and nothing else: an app with that label would receive Django’s database cache table on its alias, and Django’s duplicate-label check does not see it, since django_cache is no installed app (facts 10 and 15). The lower-case rule suits an alias too. Django’s own check admits two labels that differ only in case, and non-ASCII identifiers; an alias also ends up in command lines, file names and environment variable names, where case and normalization are not reliably kept. Python packaging normalises names to lower case for the same reason (name normalization).
  3. default is never a hosted app’s alias, and no hosted app’s tables live on it.

    • In a closed host, which includes every neolith run host in step 4, the host writes default: {}. Django makes it the dummy backend, and any database access that names no alias fails where it is made (fact 1). This is the form Django documents for a default that is not used.
    • In a host that also installs undeclared apps with models, the deployer supplies default, and those apps keep working on it unaltered. In step 4 neolith’s own test hosts do this (a test project that installs django.contrib.auth); an existing project adopting one app at a time is the case it is meant for.
    • A real default is a transitional home, not a conforming one. The apps on it share one connection, the router allows relations among them (Decision 4), and one transaction can span them. So 0004’s guarantees hold for hosted apps with a database and do not hold among the apps on default, although 0004 counts contrib apps as apps. This record does not settle how those apps come under 0004; that stays the repository’s open question 4.
    • Some data about hosted apps does land on default when contenttypes and auth are installed there: content type and permission rows for the hosted apps’ models (fact 11). These are Django’s rows about the models, not the apps’ tables, and they are one more reason a GenericForeignKey cannot be relied on across a detach (0004).
    • Weighed: default is the app’s own database when the app runs alone. This is the most familiar form, and bare transaction.atomic() would work in a detached host. But the same line would then cover the app’s writes when detached and not when attached, which is the difference in meaning principle 5 rules out, and it would show only after a mode change.
    • Weighed: default is always the dummy backend. It is the most uniform choice. It would also ask an existing project to move every table and add using= to every transaction before adopting its first app, which principle 9 and the vision’s “not a rewrite tool” do not allow.
    • Weighed: the first hosted app takes default. The meaning of app code would then depend on the order of a list.
    • The cost, stated plainly: in a host with a real default, a hosted app that writes a bare transaction.atomic() opens a transaction on default that does not cover its own writes, and nothing fails (run: one row kept after the rollback). The same app booted alone has the dummy default, where the line raises. So the boot-alone run that principle 7 already requires is what catches it, wherever the app’s tests reach that line. Django’s message there does not name the cause (fact 1); the documentation has to.
  4. The router answers every question itself. Each answer depends only on what the question names, a label or two and an alias, and on the fixed set of hosted labels; it reads no hint and no state, and it is never None.

    • db_for_read, db_for_write: the label’s alias for a hosted app with a database; default for every other label. Hints are ignored.
    • allow_migrate(db, app_label): true only when db is that label’s home. A hosted app’s tables are never created on default or on another app’s alias, and nothing else is created on an app alias.
    • allow_relation: true only when both models have the same home.
    • Every other label includes labels with no installed app, such as the database cache’s django_cache (fact 10). They go to default like an undeclared app. In a closed host that is the dummy backend, so the table refuses a database cache there when the host starts, once the cache class can be imported, instead of leaving the failure to the first cache read (K2).
    • What None would mean: Django documents the fallback as API. When no router answers a read or write, the instance hint’s _state.db is used, and default when there is no such hint (database routers); for allow_migrate the answer is true, and for allow_relation the two _state.db values are compared. Returning None is also how Django routers compose: the first answer wins, as with AUTHENTICATION_BACKENDS. The fallbacks are documented, so their stability is not the reason to avoid them. The reason is narrower: Decision 6 admits one router, so nothing follows it to give another answer, and an explicit answer keeps a hint from moving a read. Open question 6 below records the composing form as the candidate for a later record.
    • Weighed: raising from the router for a label that is not a hosted app’s, which is louder but breaks the host with a real default of Decision 3.
  5. Each alias has its own database. The host refuses two aliases that name one database, default included. Two aliases on one server with different NAMEs are admitted.

    • What is compared: for SQLite, a path. For a plain NAME, str and Path treated alike, it is the resolved absolute path. For a file: URI it is the URI’s path, percent-decoded and resolved the same way, with the query ignored, because Django opens SQLite with uri=True and all of these are one file (fact 13). The literal :memory: is exempt, as a str or a Path and before any path is resolved, being one database per connection. No other in-memory form is exempt. Two aliases that write a shared form the same way do share a database (fact 13) and are refused. Two aliases named file: with an empty path, or two named file::memory: without a shared cache, are refused as well although SQLite would give each its own, and so is an in-memory URI beside a file of the same path; that errs on the side of refusing, and 0022 refuses every in-memory form in a host that serves. For server backends, (ENGINE, HOST, PORT, NAME), with a NAME that is not empty. PostgreSQL admits an empty NAME with OPTIONS["service"] (db/backends/postgresql/base.py, get_connection_params), and MySQL’s read_default_file is the same shape; two such aliases would compare equal while naming different databases, so this record does not admit the form yet. The cost is that a deployer who keeps connection details only in a service file has to state NAME as well.
    • The check is best effort. It compares settings, not servers. It does not see: two host names or addresses for one server; an empty HOST against localhost or a socket path; a service or option file that overrides HOST or NAME; a hard link to a SQLite file; a file: URI whose query changes which file is opened, such as another vfs. PR 7’s test for this row uses the inputs listed under What is compared and no others.
    • :memory: gives every thread an empty database (fact 13), which is what a threaded server or sync_to_async would meet. It suits tests. 0022 proposes that a host refuses an in-memory database when it boots to serve, with this as the reason.
    • Why, limited to what was run: within one database and schema, two aliases share one django_migrations table, so the apps’ migration histories are entangled and a detach has to split them; Django’s test runner sets up only the first of two aliases with one signature (fact 5); and on SQLite two connections to one file block each other’s writes.
    • Weighed: one database with a schema or a table prefix per app, which 0004 allows as forms of ownership and which uses fewer databases. A PostgreSQL schema per alias through search_path would probably give each app its own django_migrations table, since the recorder creates it unqualified, and schema-per-unit is established practice (django-tenants); this was not run. What stands in its way is one thing: the test signature ignores OPTIONS, so the test runner would still set up one alias only. This record does not rule these forms out as ownership; it admits the one form it can check today (open question 4 below).
  6. DATABASES and DATABASE_ROUTERS are host output. DATABASES holds default and one key per hosted app with a database. DATABASE_ROUTERS holds the one router. On each app alias the host leaves ATOMIC_REQUESTS false and writes TEST["DEPENDENCIES"] = []; TEST["MIRROR"] stays unset everywhere.

    • ATOMIC_REQUESTS: Django would open the transaction around every view (fact 6). A view of <a> that calls <b> in-process and then fails would roll back <b>’s committed work, which cannot happen once <b> is detached. On a real default the setting keeps Django’s meaning, and that meaning reaches hosted apps too: every async view in the host then fails at its first request, whichever app owns it (fact 6). That is Django’s behaviour and the deployer’s choice; the guide should say so, since step 4’s request-root cases all run with an async view.
    • TEST["DEPENDENCIES"]: apps do not depend on each other’s data, so the empty list is the true value, and without it a test that lists only app aliases cannot start (fact 4).
    • TEST["MIRROR"]: a mirror makes two aliases one connection in tests, so two apps could share a transaction there and nowhere else.
    • TEST["NAME"]: Django’s test signature is built from the test name, not from NAME (fact 5). Two aliases with different NAMEs and one test name would share a test database: on SQLite the second alias could not write to it, and on a server backend only the first alias is set up. The setting is refused for now, which is the smaller rule; comparing test names in the pair check of Decision 5 is the form that would admit it. The cost is that a deployer cannot place or name test databases.
    • Weighed: accepting the deployer’s DATABASES and adding to it. A project keeps its file as it is, but the host then has to validate a structure it did not build, and a second router ahead of the host’s would answer first. The price of the chosen form is that a project moves its default entry into the deployer’s mapping, unchanged, and that a project with further aliases or routers of its own (replicas, tenants) is not admitted by this record. Django’s own primary and replica example is a router for default, so this is a real limit for the adopting project; open question 6 below holds the candidate that would lift it.
  7. The host’s migrate step runs per app, and Boot reads only the app’s own label. This is a property of the host’s migrate step and of Boot, not of every database.

    • The migrate step runs migrate <label> --database=<label> for each hosted app with a database. For a real default it runs migrate <label> once per undeclared app with migrations, since migrate takes one label (fact 3), and never with no label.
    • Boot’s check that a schema is current reads leaf_nodes("<label>") on that label’s home and ignores rows of other labels. Such rows exist in every test database (fact 12), and in any database a plain manage.py migrate has touched (fact 11), so Boot cannot treat them as an error.
    • Boot refuses a hosted app whose migration graph has a dependency on another label. 0004 already rules those out, the graph is loaded for the schema check anyway, and with such a dependency the per-label run would record the other app’s migrations on this alias. This check was reasoned from the source and not run.
    • Boot only reads (fact 14). It never migrates and never creates a database; a SQLite file that does not exist reads as “no migrations applied”, so the host stops with the pending-migration message. Opening the connection leaves an empty file behind, which is SQLite’s behaviour.
    • Why: a migrate with no label records every app’s migrations on the alias (fact 3), and an unfiltered plan then reports other apps’ migrations as pending on it. A database the host’s step migrated carries nothing of another app’s when it detaches.
    • Weighed: running migrate --database=<alias> unfiltered on every alias, which is the plain Django habit and needs no host support; it leaves rows for apps whose tables are elsewhere. Boot tolerates those rows either way, so the two differ only in what a detached database carries.
  8. The alias set is fixed before Django imports a model, and does not change afterwards.

    • Why: Django reads DATABASES once, at the first model class (fact 8). A host that added an alias later would be ignored without an error. DATABASE_ROUTERS is read later, at the first routing question, so a router list written between the two would silently change routing instead; the host writes both at the same moment, and 0022 compares both at set-up.
    • The keys of DATABASES come from two inputs that exist before any model is imported: the declarations of the hosted apps, which are read without their models (0021), and the deployer’s mapping. Whether default is real is the deployer’s statement in that mapping, not something the host infers from models.
    • The rows that need models (“has models but declares no database resource”, the two default rows, table names) are checked once the app registry is ready, against the DATABASES already written. They can refuse the host; they never change the alias set.
    • Settings built twice. Building from the same inputs gives the same output, so a second build is harmless. A second build with different inputs in one process cannot take effect (fact 8), so it is refused, not applied: Django’s settings.configure() already raises “Settings already configured.”, and a second django.setup() does not repopulate. Set-up is 0022’s word for the host’s work when Django loads apps, which can run again in a process (0022 Decision 5); it never changes the alias set. A change of DATABASES or DATABASE_ROUTERS after the first set-up, override_settings included, is outside the admitted set; 0022 proposes how it is detected, and names the in-place change it cannot see.
    • Telling the deployer’s DATABASES from the host’s own. The refusal in the table’s last row reads the builder’s input, never settings.DATABASES once Django holds it. So the host’s own output cannot trip it.
    • Table names. Because a default table name is cut to default’s limit (fact 9), the same app could get different table names in a closed host and in a host with a real default, and meet “no such table” on its own database. The host therefore refuses a hosted app’s table name that default shortened. In every host that starts, a hosted app’s table names are then the whole default names or the db_table values, which do not depend on the host. A name longer than the app’s own backend allows is left to that backend. PR 6 tests that a hosted app’s db_table values are equal under the dummy default and a real one.
    • Weighed: refusing any default name longer than the shortest limit among Django’s backends (30, Oracle’s). It is the same in every host by construction, but it would refuse many ordinary many-to-many names on backends that hold them without trouble.

Follows principles 4 (owned data over shared data), 5 (same semantics over same mechanics), 7 (running over linting), 9 (new and existing projects alike) and 10 (Django-native over framework-new): the alias, the router, using= and the dummy backend are all Django’s own. No decision here needs a new runtime dependency (K5).

The cases #11 lists that touch this record, and what covers each:

  • The host set up twice. For this record that is settings built twice (Decision 8): the same inputs are harmless, different inputs are refused. 0022 Decision 5 covers a later set-up, which leaves the alias set as it is.
  • Two apps with one name or one alias. The alias is the label, so the two cases are one. 0022’s builder refuses two hosted apps with one label. Django refuses any two installed apps with one label at apps.populate, with its own message (fact 15), which covers a hosted app and an undeclared one. This record adds no check of its own.
  • Pending migrations. Decision 7: Boot reads the app’s own label on its own alias and stops.
  • Two hosts on one app database. Boot only reads (fact 14), so two hosts can start against one app database safely; the migrate step is the explicit, run-once step of spec 05. The pair check of Decision 5 sees the aliases of one host only and cannot see another host.
  • Many apps, many threads. Django keeps one connection per alias per thread, so a host holds up to aliases times threads connections, not one per alias.
  • No-cross-app-transactions is enforced by the mechanism for every database call that names no alias, not by review. It holds among hosted apps with a database; it does not hold among the apps on a real default (Decision 3).
  • App code names its alias: transaction.atomic(using="<label>"), on_commit(..., using="<label>"), connections["<label>"]. Model queries need nothing, since the router places them. This is the most visible change for adopters after the stored principal ID of 0004.
  • Library code inside an app that opens a bare transaction fails in a host with the dummy default, and silently misses the app’s writes in a host with a real one (Decision 3). The guide has to say so, with Django’s message quoted so it can be searched for.
  • Every Django command and test helper that defaults to default needs the app’s alias in a closed host: migrate, dbshell, loaddata, dumpdata, flush, sqlmigrate, showmigrations, createcachetable and createsuperuser take --database, and pytest-django’s django_db marker takes databases=. runserver‘s own pending-migration warning is silent there, because check_migrations returns on the dummy backend’s ImproperlyConfigured (core/management/base.py); Boot’s schema check is then the only one. In a host with a real default the same warning is not silent and not true: it reads default with an unfiltered plan, so at every start it reports the hosted apps’ migrations as unapplied and advises a plain migrate, the command Decision 7 replaces. 0022 proposes one command for the migrate step and leaves a pass-through for the other commands as an open question.
  • Tests declare the aliases they touch (databases = {"<label>"}); Django refuses a query to any other alias with DatabaseOperationForbidden, which doubles as a check that a test of one app stays in it. A test database carries migration rows for every app (fact 12), so tests cannot assert “own rows only”.
  • An app label gains rules beyond Django’s: 0021’s grammar, and not default or django_cache. Renaming a label later renames the alias, the deployer’s key and the using= arguments, on top of the migration records and table names Django already ties to it, so a label is close to a one-way door once an app is deployed.
  • A hosted app with a long model or many-to-many name may have to set db_table before it can run beside a real default (Decision 8).
  • More connections when many apps are attached, aliases times threads, and one database per app rather than one per host. Bounding the total is the deployer’s choice and adds no dependency to neolith: an external pooler such as PgBouncer bounds it across aliases, while Django’s own PostgreSQL pool (OPTIONS["pool"], which needs the psycopg_pool extra) is one pool per alias per process, so its min_size multiplies by the number of apps. The guide should say both. One database per app is a real operating cost for small deployments.
  • PR 6 verifies fact 2 as a test: a rollback in one app’s transaction leaves the other app’s write in place; and the table-name equality of Decision 8. PR 6 also tests a refused value for the two rows it ships checks for, models without a database resource and a shortened table name. PR 7 tests every other row of the admitted set, one refused value per row, that the built DATABASES has exactly the expected keys, and Boot’s reading of its own label with other labels’ rows present.
  • The 5.2 and 6.1 sources agree on every function cited, so nothing here needs neolith._compat.
  1. Contrib apps. This record puts undeclared apps on default as a transitional home and does not say how auth, sessions and the like come under 0004. Content type and permission rows for hosted apps’ models land on default when those contrib apps are installed (fact 11). That stays the repository’s open question 4.
  2. An app that names another app’s alias. Nothing in Django stops <a> from writing transaction.atomic(using="<b>") around a call to <b>, or from opening a cursor on <b>’s connection. Aliases that follow labels make this easy to write and also easy to find: a check that flags an alias literal other than the app’s own label fits neolith check (step 7, #14).
  3. A call that re-enters an app inside its own transaction. <a> in a transaction calls <b>, which calls <a> back: in-process, the inner action sees <a>’s uncommitted rows; detached, it would not. Whether the in-process transport should refuse or isolate such a call is a transport question this record does not answer.
  4. Schema or prefix per app on one database. The known blocker is Django’s test runner, whose signature ignores OPTIONS (fact 5); whether a search_path per alias gives separate migration tables has to be run on PostgreSQL first. The same later record could admit a service or option file in place of NAME (Decision 5).
  5. A clearer failure on default. A small backend of neolith’s own could say “this host has no default database; name the app’s alias”. It was left out to keep to Django’s documented default: {}. Its known cost: Django keys behaviour to the dummy engine by name and by exception. makemigrations skips its consistency check only for ENGINE == "django.db.backends.dummy", and runserver’s check_migrations relies on ImproperlyConfigured. A replacement would have to raise that exception and would still be consistency-checked by makemigrations.
  6. Deployers with aliases or routers of their own. Read replicas and tenant routers are common in running projects, and Decision 6 does not admit them. The Django-native candidate: the host’s router goes first, answers reads and writes for hosted apps’ labels and every allow_migrate and allow_relation, and returns None for reads and writes of other labels, so a deployer’s replica router can follow. Its cost: a later router could return an app alias for an undeclared model, which the host would then have to check on every answer or at Validate.
  7. What this record leaves to the other step 4 records. 0021 owns the form of resources, which carries a kind and no alias name, and the grammar of a label. 0022 owns the name of the deployer’s mapping, the builder that Decision 8 constrains, the migrate step and the Boot check that Decision 7 constrains, the refusal of an in-memory database in a host that serves, and the detection of settings changed after set-up. If either record is not accepted in its present form, the rows of the admitted set that lean on it have to be restated here.