Test is not a flag
A routine admin command pointed production at throwaway databases, because one of its arguments was the word test.
By Andrew Pyle
One word I typed as a value decided which database production would use.
01One line of settings
The flag that read the arguments
One line in my Django settings decided whether a process counted as a test run:
# config/settings.py, before the fix
TESTING = 'pytest' in sys.modules or 'test' in sys.argvThe first half asks whether pytest is loaded. The second half was meant to catch manage.py test, the other way to run the suite. When TESTING is true, the settings switch both database aliases over to sqlite test databases.
Django computes settings at import time, for every management command, on every machine. So this line ran on the production server each time I ran anything there. Its job was to tell a test run from a real one, and it made that call on every real run too.
The second half asks whether any argument anywhere is the word test. That is a different question from whether the command is test, and the two give the same answer only as long as no other command ever takes test as a value.
Some commands do. The account admin command accepts test as a legitimate plan value. There is nothing odd about that. Test is an ordinary word, and the flag had quietly claimed it for itself.
02A go-live step
Production went looking for a sqlite file
I found it during a go-live. Step nine of the checklist for a new product was a Django management command on the production server: an account admin command, set-plan, with a plan of test. It changes one field on one row. It is the kind of command you run without much thought, near the end of a long list.
It died with a no such table error. The table was right there in production. A missing table is what you see when a database is empty, so the command was plainly looking somewhere else. The argument list was manage.py, the admin command, set-plan, --plan, test. The last element was the string test, so TESTING went true, and production pointed both aliases at the sqlite test databases.
The failed run wrote nothing. The missing table was the lucky part, since sqlite opens a file without complaint and there was no connection error to warn me. The runbook got a workaround the same day: write --plan=test with an equals sign. In that form the argument is the single string --plan=test, which never equals test, so the old check stayed quiet. That kept the go-live moving. It also left the trap in place for the next command that took test as a value.
The pull request for the real fix named that risk directly: any management command that takes a test value, this one and any future one. A word that ordinary was always going to come back as an argument.
03Detect the runner
Let the flag mean the test runner
The new line keeps the pytest check and looks only at the first argument after manage.py. The comment above it is the one I committed, with the command name trimmed:
# Only `manage.py test` (argv[1]) counts, NOT any argument that happens
# to be the word 'test'. set-plan --plan test once flipped prod onto the
# sqlite test DBs (2026-10-12).
TESTING = 'pytest' in sys.modules or sys.argv[1:2] == ['test']Only manage.py test counts, where test is the subcommand in the first position. I used a slice instead of an index, so an empty argument list cannot raise an IndexError. pytest still sets the flag through sys.modules. Because the comment names the incident, the next person who wonders why the line is written this way does not have to guess.
Testing the fix took a small trick. Settings are computed once, at import time, so I could not change sys.argv and read them again in the same process. The test loads config/settings.py in a subprocess with a chosen argument list. With the set-plan arguments, TESTING must be false. With manage.py test, it must be true. I needed the second case as much as the first, because a fix that broke the real test runner would swap one quiet failure for another. To check that the test had teeth, I put the old line back, and the first test failed. The whole thing is two tests and 27 lines.
I also searched the code with git grep for any other entry point that passes test later in the arguments and relies on sqlite. I found none. The change itself was one line, merged ten seconds after the pull request opened, with a plain revert as the rollback. A flag that decides which database production writes to should key on the thing it names. This one was written to detect a runner and detected a word instead.
Related
writing
A limit that was never enforced
Eighty of my scheduled tasks ran on a time limit different from the one they declared, because the loader quietly threw it away, and the first plan I built on those numbers was wrong.
writing
A deploy should drain, not kill
A routine deploy quietly ate a day of work, and the monitor that was supposed to notice reported nothing wrong.
writing
A status the page never learned
The tasks page went white the day a triage used a status value the client had never been told about.
writing
A cold cache is not free
My feature-flags page felt instant once I cached it, and the first request to each fresh worker after a deploy still paid about fifteen seconds.
writing
A 200 is not the same as access
My leak probe failed with seventeen leaks on its first real run. Every one of them was the same page that was never a leak.