0009. One database alias per app
- Status: Proposed
- Date: 2026-11-03, amended 2026-11-12
Context
Section titled “Context”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
defaultis. Django requires the key:ConnectionHandler.configure_settingsraises “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 upDATABASE_ROUTERSfor 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 todefault: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--databaseoption of the management commands andTestCase.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
databaseresource; - a hosted app without one: it has a declaration, no models and no
databaseresource; - an undeclared app: a plain Django app with no declaration, such as
django.contrib.author 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.
What Django does
Section titled “What Django does”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.
- With
default: {}, a baretransaction.atomic(),connection.cursor(),on_commit()andmigrateeach raiseImproperlyConfiguredfromdb/backends/dummy/base.py(complain). The text is Django’s: “settings.DATABASES is improperly configured. Please supply the ENGINE value.” 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.migrate --database=<b>with no app label writes adjango_migrationsrow 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.migratetakes at most one app label.- The test runner gives each alias other than
defaulta dependency ondefaultunlessTEST["DEPENDENCIES"]says otherwise (test/utils.py,get_unique_databases_and_mirrors; the default of['default']is documented). When a test lists only app aliases,defaultis not among the databases to set up, and the run stops with “Circular dependency in TEST[DEPENDENCIES]”. WithTEST["DEPENDENCIES"] = []on each app alias it passes. - 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 readOPTIONS. BaseHandler.make_view_atomicwraps every sync view in a transaction on every alias whoseATOMIC_REQUESTSis true, whichever app the view belongs to, and raisesRuntimeError: 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).- 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_labelandallow_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. - Django reads
DATABASESonce, at the first model class, andDATABASE_ROUTERSat the first routing question.ConnectionHandler.settingsis cached on first use, and the first use is the first model class:Options.contribute_to_classand the many-to-many table name (db/models/options.py,db/models/fields/related.py) callconnection.ops.max_name_length(). Afterdjango.setup()with onlydefault: {}, writing asettings.DATABASESwith one more alias leaveslist(connections) == ['default'], and the new alias raisesConnectionDoesNotExist.ConnectionRouter.routersis cached on its own first use, which a model class does not cause: afterdjango.setup()it was not yet cached, and aDATABASE_ROUTERSwritten then was the one Django used. Django also rebuilds the router list whenoverride_settingschanges that setting (test/signals.py). - 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 underdefault: {}, whose limit isNone, and becomes a 63-character name with a hash suffix under adefaultwhose limit is 63 (PostgreSQL’s value; the run patched SQLite’s limit to 63, no PostgreSQL server was used). Adb_tablethat is set is never cut. - Labels reach the router that belong to no installed app. The database cache backend routes under
django_cache(core/cache/backends/db.py), andcreatecachetableasksallow_migrate_modelfor it. Withdefault: {}and a router that sends unknown labels todefault,cache.get()raises the dummy backend’s message. - With
contenttypesandauthon a realdefault, rows about hosted apps’ models are written there. A plainmigrateleft 3ContentTypeand 12Permissionrows for two hosted apps’ models indefault(create_contenttypesruns onpost_migratefor every app and asks only whetherContentTypemay migrate on that alias). It also recorded both hosted apps’ migrations indefault’sdjango_migrations. - The test runner migrates every alias unfiltered, with
run_syncdb=True(db/backends/base/creation.py,create_test_db). Every test database carries adjango_migrationsrow for every app, and the tables of its own app only. - 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=sharedshare one database: the second read the table the first created, althoughis_in_memory_dbis true for both and their test signatures differ. With:memory:, each connection has its own database, and connections are per thread: a second thread gotno such tableafter the main thread had migrated. SQLite’s other shared form,file::memory:?cache=shared, is in memory too, and Django’sis_in_memory_dbis 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) andPath(":memory:")behave like:memory:: another thread gotno such table, andis_in_memory_dbis 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_listgives an empty file formain. A file is one database however it is named: Django opens SQLite withuri=True, and an alias named by a plain path, one namedfile:<that path>, one with a query string added and one with the path percent-encoded all read the table the first created. MigrationRecorder.applied_migrationsonly reads. On a SQLite file that does not exist it returns{}, and opening the connection creates an empty file.- Django refuses two installed apps with one label at
apps.populate: “Application labels aren’t unique, duplicates:<label>”.
Forces
Section titled “Forces”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.
Options
Section titled “Options”- A shared
defaultalias with a lint rule. It costs nothing at runtime. Enforcement is by review and is incomplete. - One alias per app, set by the app host, with
defaultleft 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 usedefault. - 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.
Decision
Section titled “Decision”Option 2, in the eight decisions below. Options 1 and 3 are weighed under Decision 1.
The admitted set
Section titled “The admitted set”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.
Decisions
Section titled “Decisions”-
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
defaultalias 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).
- Weighed: a shared
-
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--databaseoption,TestCase.databasesand 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_labeland 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 asneolith_<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 everyusing=. 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,defaultordjango_cache, for every hosted app, with or without a database, so that the rule does not depend on a second field.django_cacheis 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, sincedjango_cacheis 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).
- 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
-
defaultis never a hosted app’s alias, and no hosted app’s tables live on it.- In a closed host, which includes every
neolith runhost in step 4, the host writesdefault: {}. 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 adefaultthat 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 installsdjango.contrib.auth); an existing project adopting one app at a time is the case it is meant for. - A real
defaultis 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 ondefault, 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
defaultwhencontenttypesandauthare 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 aGenericForeignKeycannot be relied on across a detach (0004). - Weighed:
defaultis the app’s own database when the app runs alone. This is the most familiar form, and baretransaction.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:
defaultis always the dummy backend. It is the most uniform choice. It would also ask an existing project to move every table and addusing=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 baretransaction.atomic()opens a transaction ondefaultthat does not cover its own writes, and nothing fails (run: one row kept after the rollback). The same app booted alone has the dummydefault, 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.
- In a closed host, which includes every
-
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;defaultfor every other label. Hints are ignored.allow_migrate(db, app_label): true only whendbis that label’s home. A hosted app’s tables are never created ondefaultor 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 todefaultlike 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
Nonewould mean: Django documents the fallback as API. When no router answers a read or write, the instance hint’s_state.dbis used, anddefaultwhen there is no such hint (database routers); forallow_migratethe answer is true, and forallow_relationthe two_state.dbvalues are compared. ReturningNoneis also how Django routers compose: the first answer wins, as withAUTHENTICATION_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
defaultof Decision 3.
-
Each alias has its own database. The host refuses two aliases that name one database,
defaultincluded. Two aliases on one server with differentNAMEs are admitted.- What is compared: for SQLite, a path. For a plain
NAME,strandPathtreated alike, it is the resolved absolute path. For afile:URI it is the URI’s path, percent-decoded and resolved the same way, with the query ignored, because Django opens SQLite withuri=Trueand all of these are one file (fact 13). The literal:memory:is exempt, as astror aPathand 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 namedfile:with an empty path, or two namedfile::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 aNAMEthat is not empty. PostgreSQL admits an emptyNAMEwithOPTIONS["service"](db/backends/postgresql/base.py,get_connection_params), and MySQL’sread_default_fileis 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 stateNAMEas 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
HOSTagainstlocalhostor a socket path; a service or option file that overridesHOSTorNAME; a hard link to a SQLite file; afile:URI whose query changes which file is opened, such as anothervfs. 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 orsync_to_asyncwould 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_migrationstable, 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_pathwould probably give each app its owndjango_migrationstable, 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 ignoresOPTIONS, 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).
- What is compared: for SQLite, a path. For a plain
-
DATABASESandDATABASE_ROUTERSare host output.DATABASESholdsdefaultand one key per hosted app with a database.DATABASE_ROUTERSholds the one router. On each app alias the host leavesATOMIC_REQUESTSfalse and writesTEST["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 realdefaultthe 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 fromNAME(fact 5). Two aliases with differentNAMEs 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
DATABASESand 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 itsdefaultentry 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 fordefault, so this is a real limit for the adopting project; open question 6 below holds the candidate that would lift it.
-
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 realdefaultit runsmigrate <label>once per undeclared app with migrations, sincemigratetakes 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 plainmanage.py migratehas 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
migratewith 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.
- The migrate step runs
-
The alias set is fixed before Django imports a model, and does not change afterwards.
- Why: Django reads
DATABASESonce, at the first model class (fact 8). A host that added an alias later would be ignored without an error.DATABASE_ROUTERSis 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
DATABASEScome 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. Whetherdefaultis 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
defaultrows, table names) are checked once the app registry is ready, against theDATABASESalready 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 seconddjango.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 ofDATABASESorDATABASE_ROUTERSafter the first set-up,override_settingsincluded, is outside the admitted set; 0022 proposes how it is detected, and names the in-place change it cannot see. - Telling the deployer’s
DATABASESfrom the host’s own. The refusal in the table’s last row reads the builder’s input, neversettings.DATABASESonce 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 realdefault, and meet “no such table” on its own database. The host therefore refuses a hosted app’s table name thatdefaultshortened. In every host that starts, a hosted app’s table names are then the whole default names or thedb_tablevalues, 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’sdb_tablevalues are equal under the dummydefaultand 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.
- Why: Django reads
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).
Failure modes
Section titled “Failure modes”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.
Consequences
Section titled “Consequences”- 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
defaultneeds the app’s alias in a closed host:migrate,dbshell,loaddata,dumpdata,flush,sqlmigrate,showmigrations,createcachetableandcreatesuperusertake--database, and pytest-django’sdjango_dbmarker takesdatabases=.runserver‘s own pending-migration warning is silent there, becausecheck_migrationsreturns on the dummy backend’sImproperlyConfigured(core/management/base.py); Boot’s schema check is then the only one. In a host with a realdefaultthe same warning is not silent and not true: it readsdefaultwith an unfiltered plan, so at every start it reports the hosted apps’ migrations as unapplied and advises a plainmigrate, 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 withDatabaseOperationForbidden, 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
defaultordjango_cache. Renaming a label later renames the alias, the deployer’s key and theusing=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_tablebefore it can run beside a realdefault(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 thepsycopg_poolextra) is one pool per alias per process, so itsmin_sizemultiplies 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
databaseresource and a shortened table name. PR 7 tests every other row of the admitted set, one refused value per row, that the builtDATABASEShas 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.
Open questions
Section titled “Open questions”- Contrib apps. This record puts undeclared apps on
defaultas a transitional home and does not say howauth,sessionsand the like come under 0004. Content type and permission rows for hosted apps’ models land ondefaultwhen those contrib apps are installed (fact 11). That stays the repository’s open question 4. - An app that names another app’s alias. Nothing in Django stops
<a>from writingtransaction.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 fitsneolith check(step 7, #14). - 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. - Schema or prefix per app on one database. The known blocker is Django’s test runner, whose signature ignores
OPTIONS(fact 5); whether asearch_pathper 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 ofNAME(Decision 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 documenteddefault: {}. Its known cost: Django keys behaviour to the dummy engine by name and by exception.makemigrationsskips its consistency check only forENGINE == "django.db.backends.dummy", andrunserver’scheck_migrationsrelies onImproperlyConfigured. A replacement would have to raise that exception and would still be consistency-checked bymakemigrations. - 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_migrateandallow_relation, and returnsNonefor 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. - 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.