Status: draft / pre-implementation
Audience: maintainers extracting this package out of BPP
(/Users/mpasternak/Programowanie/bpp); future contributors.
Companion packages:
pytest-testcontainers(#1, purely fixture-based generic container primitives — maker functions, hybrid patterns. This package depends on it for the container-start primitives, not for any pytest hook.)django-pg-baseline(#3, manages abaseline.sqlartifact, this package optionally consumes its output viaget_baseline_path().)
Bridge between pytest-testcontainers (#1) and pytest-django. Solves
a single, concrete, ugly timing problem that every Django project trying
to use testcontainers eventually hits:
Django reads its
DATABASESconfig fromos.environat import time. Pytest-django imports settings during its ownpytest_load_initial_conftestshook. Any fixture that starts a container — including session-scoped, autouse fixtures — runs after pytest-django imports settings. By the time the test container has a port, Django already opened a connection (or failed to) against whatever was in.envwhen pytest started.
The only correct hook for "start a container, write its port to
os.environ, before Django imports settings" is pytest_load_initial_conftests
itself, registered with @pytest.hookimpl(tryfirst=True). That single
detail is the core IP of this package. The rest is plumbing around
it: xdist worker propagation, dotenv suppression, init-script mounting,
TEST TEMPLATE setting, cleanup ordering.
The canonical write-up of this race condition lives today in BPP at
src/testcontainers_bpp/plugin.py, lines 63–92. Direct quote (Polish
preserved, as it's part of the historical record of why this package
exists):
Pytest-django ma własny hook
pytest_load_initial_conftests, w którym wymusza załadowanie ustawień Django (dj_settings.DATABASES,pytest_django/plugin.py:357). To powoduje import modułudjango_bpp.settings.base, który czyta parametry połączenia zos.environw momencie importu — np.env("DJANGO_BPP_DB_PORT")w linii 679.Deweloper zazwyczaj ma w shellu (przez direnv /
.env)DJANGO_BPP_DB_PORT=5432— domyślny port docker-compose. Jeżeli Django załaduje ustawienia ZANIM ta funkcja ustawi porty z testcontainerów, to Django połączy się z portem 5432 (albo dostanie „Connection refused" gdy docker-compose nie działa). Dynamiczny port z testcontainerów (np. 63403) nigdy nie dotrze do Django.
Translation of intent: this gap between os.environ snapshot and
Django settings import is the root cause. There is no fixture-level fix.
The solution is hook-level + tryfirst.
This package exists so other Django projects don't have to rediscover that detail by debugging "Connection refused" against the wrong port for two evenings.
After several iterations, the boundary between #1 and #2 settled on purely fixture-based for #1 and eager-start hook for #2:
- #1 (
pytest-testcontainers) is a generic, framework-agnostic helper package. It exposes maker functions (make_postgres,make_redis,make_minio, …) and hybrid patterns for users who want to wire up containers as ordinary pytest fixtures (session-scoped, module-scoped, function-scoped — their choice). It registers no pytest hooks. It reads no project-level config files. It does nothing at session start. Non-Django projects use #1 standalone. - #2 (this package) is the Django glue layer. It owns the
pytest_load_initial_conftests(tryfirst=True)hook, the env-var injection, the dotenv-skip flag, the TEST TEMPLATE wiring, the init-scripts mount contract, and xdist worker propagation. It importsmake_postgres/make_redisfrom #1 to actually instantiate containers, and that's the entire dependency surface upward.
Why this split? Because the eager-start machinery is Django-specific.
No other framework reads DATABASES at module import time the way Django
does. pytest-testcontainers (#1) staying purely fixture-based keeps
it useful for FastAPI, Flask, Pyramid, plain library testing, and any
other context where "fixtures resolve at test time" is the natural
contract. Hauling the eager-start engine into #1 would force every
non-Django user to think about hook ordering they don't need.
pytest_load_initial_conftests(tryfirst=True)implementation that:- Reads project config (pyproject.toml table or
register()call). - Calls #1's maker functions (
make_postgres(...),make_redis(...)) to start containers on the controller process. - Injects connection details into
os.environunder project-configured names. - Sets a
*_TEST_TEMPLATEenv var so Django creates the test DB byCREATE DATABASE … WITH TEMPLATE. - Sets a
*_SKIP_DOTENVflag so django-environ doesn't clobber injected values. - Detects pytest-xdist workers and skips re-starting containers (workers inherit env vars from the controller).
- Reads project config (pyproject.toml table or
- A small configuration model (env-var name mapping, image, credentials,
init scripts, TEMPLATE setting) declared in
pyproject.tomland/or registered from a project'sconftest.py. - Optional Redis support via the same configuration model.
--no-testcontainersCLI flag and an env-var equivalent for disabling the plugin (delegating to docker-compose / pre-existing services).- Optional
BPP_TESTCONTAINERS_REUSE-style reuse mode for fast local iteration (delegated to #1's reuse semantics). - atexit safety net for graceful pytest-process exits.
- Helper for consuming a SQL file from #3 (mount → init scripts → TEMPLATE).
- Generic "start any container" lifecycle. That is #1's job. This package only knows about Django's contract with the container.
- Fixture-based container lifecycles for arbitrary services. That is
#1's job. If a user wants
tc_minioper-session, they declare a fixture using #1'smake_miniodirectly. We don't proxy. - Managing the SQL artifact itself (creation, refresh, version pinning). That is #3's job.
- Replacing pytest-django. We integrate with it; we do not subsume it.
- Provisioning Docker on host machines, Docker-in-Docker tricks, CI service-container detection. The user is responsible for a working Docker daemon.
- Supporting non-pytest-django setups (e.g. Django's stock
manage.py test). Hook is pytest-only. - Fixturized container lifecycle for the DB and Redis services we
manage. Containers in this package are always process-scoped,
started in
pytest_load_initial_conftests, stopped inpytest_unconfigure+ atexit. Fixture-level scopes are too late for the timing dance — see §6.
This is a narrow, surgical dependency. After the architectural split (see §1.1), #2 consumes only the container-start primitives from #1 — nothing else.
from pytest_testcontainers import (
DockerNotRunningError,
make_postgres, # spec → started PostgresContainer handle
make_redis, # spec → started RedisContainer handle
)That's it. Two maker functions and one exception class. The maker functions are synchronous calls that:
- Pull the image if missing.
- Start the container with the requested env, ports, mounts.
- Wait for the readiness signal (TCP for Postgres, PING for Redis).
- Return a handle exposing
.host,.port,.stop()and image-specific helpers (e.g..connection_url()).
- No fixtures. We never
pytest.fixture-decorate anything from #1's surface. #1's session-scoped fixtures (tc_postgres,tc_redis) resolve too late — by the time the first test asks for them, pytest-django has already imported settings. - No pytest hooks. #1 ships no hooks under the new architecture.
Even if it did, we'd not use them: the timing contract (
tryfirst, ordering against pytest-django) is #2's concern alone. - No env-var injection from #1. #1's maker functions return
handles. Anything that touches
os.environis #2's job. - No reading of
[tool.pytest-testcontainers]config from #1. #1 has no controller-level pyproject reader. Our config table is[tool.pytest-testcontainers-django]and we read it ourselves.
The eager-start machinery (hook, env injection, dotenv suppression, xdist worker propagation, init-scripts mounting, TEST TEMPLATE wiring) lives entirely in #2. Non-Django users never import #2 and never pay the cost of the timing dance.
Django projects that need additional services (Elasticsearch,
MinIO, Kafka) beyond eager-started DB+Redis declare plain pytest
fixtures using #1's makers. Late resolution is fine for those —
their host:port is read at connection time, not import time. Only
Django's DATABASES has the import-time-read race.
# project conftest.py — coexistence pattern
import pytest
from pytest_testcontainers import make_minio # #1's maker
@pytest.fixture(scope="session")
def minio():
container = make_minio(image="minio/minio:latest")
yield container
container.stop()#2 pins #1 to a compatible major (pytest-testcontainers>=1,<2).
Independent release cadence; breaking changes to #1's maker signatures
require coordinated release.
This is the path most projects should take. Zero conftest.py needed.
[tool.pytest-testcontainers-django]
# Postgres container.
postgres_image = "postgres:16"
postgres_user = "postgres"
postgres_password = "postgres"
postgres_database = "postgres"
postgres_internal_port = 5432
# Optional: image-specific knobs forwarded as container env.
postgres_env = { POSTGRESQL_UNSAFE_BUT_FAST = "1" }
# Optional: load SQL files as init scripts. Paths are interpreted
# relative to the project root (where pyproject lives).
postgres_init_scripts = ["tests/fixtures/baseline.sql"]
# Optional: name of DB after init scripts ran; used as the source for
# CREATE DATABASE test_<name> WITH TEMPLATE <postgres_template>.
postgres_template = "postgres"
# Env-var name mapping. These are the names #2 will write into
# os.environ. Project's settings.py reads from these same names.
db_host_env = "DJANGO_DB_HOST"
db_port_env = "DJANGO_DB_PORT"
db_name_env = "DJANGO_DB_NAME"
db_user_env = "DJANGO_DB_USER"
db_password_env = "DJANGO_DB_PASSWORD"
db_test_template_env = "DJANGO_DB_TEST_TEMPLATE"
skip_dotenv_env = "DJANGO_SKIP_DOTENV"
# Optional: Redis.
redis_enabled = false
redis_image = "redis:7-alpine"
redis_internal_port = 6379
redis_host_env = "DJANGO_REDIS_HOST"
redis_port_env = "DJANGO_REDIS_PORT"
# Disable / reuse switches (env-var names that the user sets,
# not values).
disable_env = "PYTEST_TESTCONTAINERS_DISABLE"
reuse_env = "PYTEST_TESTCONTAINERS_REUSE"Plus the always-available CLI flag --no-testcontainers.
For projects that need conditional configuration, dynamic image selection, or want to wire #3 in directly:
# conftest.py at project root
from pathlib import Path
from pytest_testcontainers_django import (
DjangoContainerConfig,
PostgresService,
RedisService,
register,
)
register(
DjangoContainerConfig(
postgres=PostgresService(
image="postgres:16",
user="myapp",
password="myapp",
database="myapp",
init_scripts=[Path("tests/fixtures/baseline.sql")],
template="myapp",
env={"POSTGRESQL_UNSAFE_BUT_FAST": "1"},
env_names={
"host": "MYAPP_DB_HOST",
"port": "MYAPP_DB_PORT",
"name": "MYAPP_DB_NAME",
"user": "MYAPP_DB_USER",
"password": "MYAPP_DB_PASSWORD",
"test_template": "MYAPP_DB_TEST_TEMPLATE",
},
),
redis=RedisService(
image="redis:7-alpine",
env_names={
"host": "MYAPP_REDIS_HOST",
"port": "MYAPP_REDIS_PORT",
},
),
skip_dotenv_env="MYAPP_SKIP_DOTENV",
)
)register() is callable from a conftest.py at module top level —
that file is imported during pytest's bootstrap, before the
pytest_load_initial_conftests hook runs, so the registered config
is available when the hook fires. (See §6 for the ordering proof.)
If both pyproject.toml and register() are present, register()
wins — predictable for the "I needed to override one thing dynamically"
case.
BPP today hardcodes everything BPP-specific in plugin.py (env names,
image, credentials, template DB). After migration, BPP's
pyproject.toml carries the configuration via the keys above —
postgres_image = "iplweb/bpp_dbserver:psql-16.13",
postgres_user/password/database = "bpp"/"password"/"bpp",
db_*_env = "DJANGO_BPP_DB_*", skip_dotenv_env = "DJANGO_BPP_SKIP_DOTENV",
disable_env = "BPP_USE_TESTCONTAINERS", reuse_env = "BPP_TESTCONTAINERS_REUSE". See §13 for the full BPP→generic mapping.
Plus a one-line register() hook in conftest.py that resolves
baseline.sql's location through #3 — see §10.
For each setting:
register()value ifconftest.pycalled it (highest priority).[tool.pytest-testcontainers-django]table in nearestpyproject.tomlwalking up from rootdir.- Built-in defaults (sensible:
postgres:16, env var names with theDJANGO_DB_*/DJANGO_REDIS_*/DJANGO_DB_TEST_TEMPLATE/DJANGO_SKIP_DOTENVprefix).
Required — none. Zero-config invocation must work for a project that
has DATABASES['default']['HOST'] reading from DJANGO_DB_HOST etc.,
with default Postgres credentials. Realistic defaults beat documentation.
Optional — everything else (custom env-var names, custom image, init scripts, Redis, reuse, disable).
This is the single most important configuration concept. Different projects use different prefixes:
| Project | Prefix |
|---|---|
| BPP | DJANGO_BPP_* |
| Django default tutorial | DJANGO_* |
| 12-factor / Heroku | DATABASE_URL |
| django-environ idiom | DATABASE_URL or |
individual DB_* |
We support the individual env-var style natively. DATABASE_URL
parsing is out of scope for v1 — it's a separate concern (the
url-format itself is fine, but injecting "compose a URL from a dynamic
port" through os.environ["DATABASE_URL"] is just as easy from the
caller side and we'd inherit the URL-parser bug surface). See §13
for revisit-later notes.
Pass-through for image-specific knobs. BPP sets
POSTGRESQL_UNSAFE_BUT_FAST=1 and
POSTGRESQL_MAX_LOCKS_PER_TRANSACTION=512 on its custom
iplweb/bpp_dbserver image. This is opaque to us — we just forward it
to #1's make_postgres(env=...) parameter.
Most images use 5432 / 6379. Some don't (Postgres images sometimes expose 15432, custom-built images do as they please). Configurable; defaults match upstream official images.
At hook time, before starting containers:
- Reject combinations like
redis_enabled=truewith noredis_host_env/redis_port_env. - Reject
init_scriptspaths that don't exist (fail loudly: don't silently fall back to "no init", that's the BPPfind_baseline_sqlwarning that nobody reads). - Reject
postgres_templateset without anypostgres_init_scripts— TEMPLATE pointing at an empty template DB just slows down test DB creation for no reason.
This section is the educational core of the package. Anyone reading the source should leave understanding why the hook ordering matters. The full explanation lives here, not in #1's spec — #1 is purely fixture-based and never deals with this race.
Django settings are evaluated at module-import time. A typical
settings.py has top-level code like:
import environ
env = environ.Env()
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": env("DJANGO_DB_NAME"),
"USER": env("DJANGO_DB_USER"),
"PASSWORD": env("DJANGO_DB_PASSWORD"),
"HOST": env("DJANGO_DB_HOST"),
"PORT": env("DJANGO_DB_PORT"),
},
}When django.setup() runs, it imports the settings module. Every
env(...) call resolves against os.environ at that moment. After
import, the resolved values are baked into settings.DATABASES.
Mutating os.environ afterward changes nothing for that connection.
Worse, third-party Django apps imported during settings load can
themselves read settings.DATABASES['default']['HOST'] immediately
(SSL options blocks, connection pool init, schema introspection) and
cache that value — even a post-setup settings.DATABASES['default'] ['HOST'] = ... mutation won't reach them.
Other frameworks don't have this property. FastAPI, Flask, Pyramid all read DB config at application startup or at connection time. If your testcontainer port is available by the time the app starts (or the first request is served), you're fine. Their config models are re-bindable.
This is why fixtures work for non-Django projects but not for Django: the import-time read happens before any fixture runs.
A fixture — even session-scoped, even autouse — resolves only when a
test (or another fixture) requests it. The fixture machinery itself
runs inside pytest_collection and pytest_runtest_setup, both of
which fire after pytest_configure, which fires after
pytest_load_initial_conftests. By the time any fixture body executes,
pytest-django has already done django.setup() and Django has already
read os.environ.
Concretely, here's what a session-scoped autouse fixture would look like, and why it fails:
# does NOT work — too late
@pytest.fixture(scope="session", autouse=True)
def _start_pg():
pg = make_postgres(...)
os.environ["DJANGO_DB_PORT"] = str(pg.port)
yield pg
pg.stop()By the time _start_pg runs, connection.settings_dict["PORT"] is
already the wrong port (whatever was in .env at pytest startup,
typically 5432). Setting os.environ here is dead code from Django's
perspective — Django doesn't re-read it.
The only place a hook can run before django.setup() is itself called
inside a hook fired earlier than pytest-django's
pytest_load_initial_conftests. The earliest such hook is
pytest_load_initial_conftests itself, registered with tryfirst=True.
When pytest starts with --ds=myapp.settings, the following sequence
fires (simplified, but accurate to pytest 7+ and pytest-django 4+):
pytestparses CLI args and discovers plugins (entry points,-pflags,conftest.pyimports near rootdir).pytest_cmdline_mainruns. Conftest plugins (registered viaentry_pointsinpyproject.toml) are loaded.pytest_load_initial_conftestshooks fire, in pluggy order (tryfirst → default → trylast).pytest_configurehooks fire.- Collection starts.
- Tests run.
pytest_unconfigurehooks fire.
Inside step 3, both pytest-django and this package register a
pytest_load_initial_conftests hook:
- pytest-django's hook (around
pytest_django/plugin.py:357in current versions): forcesdjango.setup(), which imports the user's settings module, which readsDATABASESfromos.environ(or from django-environ which readsos.environ). - This package's hook: starts containers via #1's makers, writes
os.environ.
Without tryfirst=True, pluggy executes hooks LIFO with no order
guarantee between two competing default-priority registrations. In
practice, pytest-django often wins because its plugin is loaded earlier
in the entry-point graph. Result: Django imports settings against stale
os.environ, connects to whatever port your .env had (typically
5432), gets Connection refused (or worse: connects to a real
docker-compose Postgres that has stale data), and tests fail with
opaque "OperationalError" messages that look like docker-compose
problems.
@pytest.hookimpl(tryfirst=True) forces our hook to the front of the
queue. Pluggy honors tryfirst deterministically. Even if
pytest-django decides tomorrow to mark its own hook with tryfirst
too, tryfirst between two implementations falls back to LIFO
registration order, and we control our own registration via entry
points — we can make sure our plugin loads first by virtue of being
the one most directly responsible for env vars.
Counter-question: why not trylast on pytest-django? We don't own
pytest-django and patching its decorators is fragile. Our tryfirst
is robust.
Counter-question: why not pytest_configure(tryfirst=True)? Because
pytest_configure fires after pytest_load_initial_conftests, and
pytest-django uses the latter for django.setup(). Hooking
pytest_configure is too late.
This is the canonical sequence to put in the README and in inline docstrings. Rehearse it until it's automatic.
- User runs
pytest. Shellos.environhas e.g.DJANGO_DB_PORT=5432from direnv /.env. - pytest discovers our entry point and pytest-django's entry point.
Plugins are imported (top-level code runs;
register()calls in the user'sconftest.pyexecute now). - pytest fires
pytest_load_initial_conftests. Pluggy looks at all registered impls. - Our impl, marked
tryfirst=True, runs first. - We check
--no-testcontainersand thedisable_envenv var — bail if set (user is delegating to docker-compose / pre-existing services). - We check
PYTEST_XDIST_WORKER— if we're a worker, set theskip_dotenv_envflag and return (the controller already started containers and exported env; the worker inherited it on fork/spawn). See §7. - We resolve config (pyproject.toml +
register()overrides). - We delegate to #1:
make_postgres(image=..., user=..., password=..., database=..., env=..., init_scripts=..., port=...)→PostgresContainer(host="localhost", port=63403, ...). Ifredis_enabled,make_redis(...)similarly. - We write to
os.environ:<db_host_env>=localhost<db_port_env>=63403<db_name_env>=<postgres_database><db_user_env>=<postgres_user><db_password_env>=<postgres_password><db_test_template_env>=<postgres_template>(if configured)<redis_host_env>/<redis_port_env>(ifredis_enabled)<skip_dotenv_env>="1"
- We register
atexit.register(_stop)as a safety net for abrupt-exit paths that skippytest_unconfigure. - Our hook returns. Pluggy moves on.
- pytest-django's impl runs. It triggers
django.setup()→ settings module imports →DATABASESis read from the freshly populatedos.environ. dotenv loading is skipped because of the skip flag, so our injected ports survive. - pytest-django sees
DATABASES['default']['TEST']['TEMPLATE'] = "..."(the project's settings code wired the env var to the TEST dict), so when it eventually creates the test DB, it doesCREATE DATABASE test_<name> WITH TEMPLATE <name>— instant. - Collection runs, fixtures resolve, tests execute.
pytest_unconfigurefires; we stop containers (unless reuse mode).- atexit runs as safety net (no-op if
pytest_unconfigurealready cleaned up).
Skip step 4. Suddenly pytest-django runs first at step 12, reads
DJANGO_DB_PORT=5432, opens psycopg connection. Either:
- That port has nothing on it →
OperationalError: connection refused. - That port has docker-compose Postgres → tests run against the wrong DB, pollute developer state, possibly succeed silently (until a test expects baseline data and the wrong DB has different rows).
The second case is worse than the first.
Imagine tryfirst=True works and our hook ran first, but the project's
settings.py does (verbatim from BPP base.py:186):
if not os.environ.get("DJANGO_BPP_SKIP_DOTENV"):
for fn in ENVFILE_PATHS:
if os.path.exists(fn) and os.path.isfile(fn):
environ.Env.read_env(fn, overwrite=True)Without the SKIP_DOTENV flag set, read_env(..., overwrite=True)
clobbers our just-injected DJANGO_BPP_DB_PORT=63403 with the value
from .env (5432). Now os.environ has 5432 again, settings.py
proceeds to read DJANGO_BPP_DB_PORT from os.environ, and Django
connects to 5432. Same failure mode as no-tryfirst, different
mechanism. See §9 for the full explanation.
Without the worker check (§7), each xdist worker starts its own
containers — N extra Postgreses fighting for ports, workers connect
to their own (baseline-less) DBs, CREATE DATABASE … WITH TEMPLATE
fails. Our PYTEST_XDIST_WORKER early-return prevents this.
Inherited verbatim from BPP's plugin (is_xdist_worker() check in
plugin.py:54-60). Logic:
- pytest-xdist's controller process forks/spawns workers. Workers
inherit the controller's
os.environ, including the env vars we injected. - In a worker,
PYTEST_XDIST_WORKERis set to the worker name (e.g.gw0). - When we detect a worker, we do not start new containers — we'd fight with the controller for ports and produce N stale containers.
- We do still need to set
<skip_dotenv_env>=1in the worker. Why: the worker re-importsconftest.pyand thensettings.py, which may trigger django-environ to re-read.envand overwrite the inherited port. Setting the skip flag short-circuits that.
def _is_xdist_worker() -> bool:
return "PYTEST_XDIST_WORKER" in os.environWorkers START NO CONTAINERS for the eager-started DB+Redis services
we manage. But workers CAN still use #1's fixture-based containers
freely — for example, a session-scoped MinIO via a tc_minio fixture
defined in the project's conftest. Each worker gets its own fixture
instance (because pytest-xdist runs pytest_collection per worker,
and session-scoped fixtures are per-worker session).
This is fine. Per-worker MinIOs use ephemeral ports and don't conflict. The eager-start env (DB+Redis) propagates from controller → workers once at fork; the fixture-managed services are independent per worker.
If a project has a hot per-worker DB requirement (e.g. parallel test isolation requiring fresh DBs per worker), this package isn't the right tool — they should drop our eager-start mode and use #1 fixtures all the way down. We don't try to be clever about per-worker DBs in v1.
When a worker is replaced mid-run, the new worker still inherits the
controller's env at fork time, so we're fine. PYTEST_XDIST_WORKER is
set in the new process, our worker branch executes, no containers
start, env is already present.
Considered alternative: instead of writing os.environ, have the
project import from pytest_testcontainers_django import override_databases
in settings.py and call it after the DATABASES dict is constructed.
The override would mutate the dict in place, replacing host/port with
container values. No env-var indirection.
Why rejected for v1:
- Intrusive. Asking projects to add a
from pytest_testcontainers_django import ...tosettings.pyis the opposite of zero-touch. The import has to be conditional on "are we running under pytest" — butsettings.pydoesn't know that without sniffingsys.argvor stashing a marker, both fragile. The env-var contract is declarative: settings reads env vars regardless of who's running. - Django doesn't actually have a clean API to "re-bind DATABASES
after setup". You can mutate
settings.DATABASES['default'] ['HOST'], but any code that read it during settings import (e.g. CONN_MAX_AGE logic, SSL options block, third-party packages readingsettings.DATABASESat import time) keeps the old value. BPP itself has such a block:if (DATABASES["default"]["HOST"] in ["localhost", "127.0.0.1"]) or env("DJANGO_BPP_DB_DISABLE_SSL"):that mutatesOPTIONS. Re-running that logic post-setup is project-specific and brittle. - The env-var path is what BPP, and most django-environ-using projects, are already doing. Pre-existing settings already read from env. We'd have to teach users a second contract.
v2 maybe: a DjangoSettingsOverride plugin mode for greenfield
projects that don't want django-environ. Out of scope for v1; documented
here so we don't re-litigate.
The *_SKIP_DOTENV env var (BPP: DJANGO_BPP_SKIP_DOTENV) is the
second load-bearing detail of the package, and it's underdocumented
even in BPP's own codebase.
BPP's settings/base.py:186:
if not os.environ.get("DJANGO_BPP_SKIP_DOTENV"):
for fn in ENVFILE_PATHS:
if os.path.exists(fn) and os.path.isfile(fn):
environ.Env.read_env(fn, overwrite=True)Note the overwrite=True. django-environ's read_env(..., overwrite=True)
will clobber anything already in os.environ with values from
.env. So the sequence
os.environ["DJANGO_BPP_DB_PORT"] = "63403" (us) → read_env(.env, overwrite=True)
(settings.py at import) leaves us with DJANGO_BPP_DB_PORT=5432 again.
The skip flag short-circuits this. The settings module reads the flag before dotenv loading, so when the flag is set, dotenv files are ignored entirely.
We document a hard requirement: the project must respect a configurable
"skip dotenv" env var in its settings file. We can't enforce this
from inside the plugin (we don't know the user's settings file), but
we'll detect it at first-run by checking whether our injected
*_DB_PORT made it through to connection.settings_dict["PORT"] and
warn loudly if not.
The flag name is configurable (skip_dotenv_env) so projects with no
dotenv loading at all can ignore the contract — they set
skip_dotenv_env = "" to disable the injection. This is the
"my settings doesn't use django-environ" escape hatch.
Auto-monkey-patch django-environ's Env.read_env. Rejected:
django-environ isn't a hard dep, projects use python-decouple,
django-configurations, vanilla os.getenv, or hand-rolled parsers.
We can't safely patch all of them. The env-var contract is portable.
Postgres official image (and BPP's iplweb/bpp_dbserver) honors
/docker-entrypoint-initdb.d/. Any *.sql, *.sql.gz, or *.sh in
that directory runs on first cluster init, before the container
starts accepting TCP connections. testcontainers' wait strategy waits
for TCP, so by the time we get a port back, the dump is already loaded.
This is significantly faster than the alternative (start empty container,
psql -f baseline.sql from the host) because:
- No host-side
psqlneeded. - No TCP round-trips for every
INSERT. - The dump is loaded in a single in-process pass.
postgres_init_scripts = [
"tests/fixtures/baseline.sql",
"tests/fixtures/extensions.sql",
]
postgres_template = "myapp"Each path is mounted as /docker-entrypoint-initdb.d/NN-name.sql
where NN is a 2-digit zero-padded index (preserves order). .sql.gz
is detected by extension. Script paths in pyproject are interpreted
relative to project root; in register() calls, Path objects are
resolved as-is (allow absolute paths).
django-pg-baseline provides a SQL artifact. Its API contract toward
this package (defined in #3's spec, summarized here):
# django_pg_baseline/api.py — provided by #3
def get_baseline_path() -> Path:
"""Return resolved path to baseline.sql.
Raises BaselineNotBuiltError if the artifact hasn't been generated.
Raises BaselineStaleError if the artifact is older than the
relevant migration set (configurable; #3's policy).
"""Two integration paths:
Path A — via conftest.py (recommended, primary):
# project conftest.py
from django_pg_baseline import get_baseline_path
from pytest_testcontainers_django import register, DjangoContainerConfig, PostgresService
register(DjangoContainerConfig(
postgres=PostgresService(
image="postgres:16",
init_scripts=[get_baseline_path()],
template="myapp",
# ... env names
),
))Direct Python; no string-based magic. Errors fail fast at import time with a stack trace pointing at conftest.py.
Path B — via pyproject.toml flag (supported but secondary):
[tool.pytest-testcontainers-django]
use_django_pg_baseline = true # imports #3 and prepends its path
postgres_init_scripts = [
# additional scripts after baseline; baseline is auto-prepended
]
postgres_template = "myapp"When use_django_pg_baseline = true, the plugin imports
django_pg_baseline and calls get_baseline_path() at hook time,
prepending the resolved path to postgres_init_scripts. If #3 is
not installed, hook fails with a clear error: install
django-pg-baseline or set the flag to false.
Decision: v1 supports both Path A and Path B. Path A is documented as the recommended hookup. Path B is for "I want zero conftest.py" projects.
We do not support a string-sigil DSL like "@django-pg-baseline:..."
inside postgres_init_scripts lists — that's a tiny in-house DSL,
worse than the flag.
Mounting init scripts is half the story. The other half is telling Django to clone the loaded DB instead of running migrations from scratch.
We write os.environ[db_test_template_env] = postgres_template. The
project's settings.py is responsible for translating that to
DATABASES['default']['TEST']['TEMPLATE']. BPP does this in
base.py:715-719:
_test_template = env("DJANGO_BPP_TEST_TEMPLATE")
if _test_template:
test_settings = DATABASES["default"].get("TEST", {})
test_settings["TEMPLATE"] = _test_template
DATABASES["default"]["TEST"] = test_settingsDocument this snippet as the canonical settings-side glue. Copy it into projects, change the env-var name. Three lines. Don't make this a Python import users have to do.
Django creates the test DB as test_<NAME> by default. Cloning from
the original <NAME> works, but means the template DB is the
production-shaped DB on the container. This is fine for testcontainers
(the container is throwaway), but document the gotcha for users
running this against a shared/persistent DB (which they shouldn't,
but).
When postgres_init_scripts is non-empty and postgres_template
is unset, the plugin defaults postgres_template = postgres_database.
Reasoning: init scripts replay into postgres_database (that's
what the Postgres entrypoint does), so the only DB that has the
loaded baseline is postgres_database. Cloning a different name
would clone an empty template0/template1. This DRY default
prevents the most common "I set up the dump but tests still see
empty tables" footgun. User can override explicitly for advanced
setups (see open question §14.9).
Postgres runs /docker-entrypoint-initdb.d/ only on first cluster
init. When reuse mode attaches to a pre-existing container:
[pytest-testcontainers-django] reuse mode + init_scripts:
init scripts NOT replayed against the existing container
(Postgres only runs /docker-entrypoint-initdb.d/ on first init).
To re-apply: stop and remove the container, then re-run.
Suggested: docker rm -f <container-name>
This is a one-line stderr warning, not a failure — tests run against whatever state the existing container has. v1 does not hash-fingerprint init scripts. Reasoning: reuse mode is for fast iteration on a known-good baseline; baselines change rarely, and when they do you bump version → fresh container → init scripts replay. (See open question §14.10 for hash-check follow-up.)
BPP's plugin starts Redis alongside Postgres. Redis is in scope here
for ergonomics — most Django projects with a DB need a cache, and the
timing dance is identical (settings read REDIS_HOST / REDIS_PORT
at import time; same race, same fix).
redis_enabled=true toggles a built-in Redis service. We do not
generalize to "configure N arbitrary containers from pyproject.toml".
Reasons:
- Two well-known services (Postgres, Redis) cover ~95% of Django stacks.
- Generalizing to "any container" reinvents docker-compose poorly.
- For the 5% case (Elasticsearch, Kafka, Localstack, MinIO), the user
declares a fixture using #1's maker functions directly. That's late
enough, because non-DB services typically read their host:port from
settings at connection time rather than import time. The race only
matters for
DATABASES. See §3.3 for the coexistence pattern.
See §4.1. Redis env names map to redis_host_env and redis_port_env
only — DB number, password, sentinel, etc., are out of scope. Project
settings can fold the host:port into a redis://... URL.
BPP also sets DJANGO_BPP_REDIS_DB_BROKER, DJANGO_BPP_REDIS_DB_CACHE,
etc. — those are logical DB indices, static, not container-related.
We do not touch those. Project settings retain them exactly as today.
Standard src/pytest_testcontainers_django/ layout with modules for:
plugin (hook impls), config (pyproject parsing, validation), injection
(os.environ writer), containers (bridge to #1's makers), xdist (worker
detection), and _types. Tests cover hook ordering (our hook before
pytest-django), env injection, dotenv skip, xdist workers, init
scripts, template DB, and #3 integration.
Runtime deps: pytest>=7,<9, pytest-django>=4,
pytest-testcontainers (#1, version-pinned to the maker function API
in §3), tomli; python_version<'3.11'.
Optional deps: pytest-xdist (detected via env var, no install-time
dep), django-pg-baseline (#3, activated by
use_django_pg_baseline = true).
Support matrix: Python 3.10+, Django 4.2 LTS / 5.0 / 5.1 / 5.2 LTS, pytest-django 4.x. CI matrix on GitHub Actions with a Docker service container providing the daemon.
Entry point:
[project.entry-points."pytest11"]
testcontainers_django = "pytest_testcontainers_django.plugin"Auto-loaded — no -p flag or conftest.py import required.
PyPI name: pytest-testcontainers-django is available (404 on
pypi.org/pypi/pytest-testcontainers-django/json/).
Mapping of every BPP-specific identifier currently embedded in
src/testcontainers_bpp/plugin.py and containers.py to its
generalized form. After the architectural split, all of these
live in #2's config (either pyproject.toml or register() args) —
none of them leaks into #1.
| BPP today | Generalized config key | Default |
|---|---|---|
DJANGO_BPP_DB_HOST (env name) |
db_host_env |
DJANGO_DB_HOST |
DJANGO_BPP_DB_PORT |
db_port_env |
DJANGO_DB_PORT |
DJANGO_BPP_DB_NAME |
db_name_env |
DJANGO_DB_NAME |
DJANGO_BPP_DB_USER |
db_user_env |
DJANGO_DB_USER |
DJANGO_BPP_DB_PASSWORD |
db_password_env |
DJANGO_DB_PASSWORD |
DJANGO_BPP_TEST_TEMPLATE |
db_test_template_env |
DJANGO_DB_TEST_TEMPLATE |
DJANGO_BPP_SKIP_DOTENV |
skip_dotenv_env |
DJANGO_SKIP_DOTENV |
DJANGO_BPP_REDIS_HOST |
redis_host_env |
DJANGO_REDIS_HOST |
DJANGO_BPP_REDIS_PORT |
redis_port_env |
DJANGO_REDIS_PORT |
iplweb/bpp_dbserver:psql-16.13 |
postgres_image |
postgres:16 |
bpp / password / bpp (pg credentials) |
postgres_user / postgres_password / postgres_database |
postgres ×3 |
5432 |
postgres_internal_port |
5432 |
redis:7-alpine |
redis_image |
redis:7-alpine |
6379 |
redis_internal_port |
6379 |
bpp (template DB name) |
postgres_template |
unset |
BPP_USE_TESTCONTAINERS |
disable_env |
PYTEST_TESTCONTAINERS_DISABLE |
BPP_TESTCONTAINERS_REUSE |
reuse_env |
PYTEST_TESTCONTAINERS_REUSE |
BPP_BASELINE_SQL_PATH |
(removed) | use postgres_init_scripts directly |
bpp-tc-pg / bpp-tc-redis (reuse names) |
(delegated to #1's reuse) | #1 picks names |
POSTGRESQL_UNSAFE_BUT_FAST=1 |
postgres_env (mapping) |
empty |
POSTGRESQL_MAX_LOCKS_PER_TRANSACTION=512 |
postgres_env (mapping) |
empty |
| docker-compose label override hack | (delegated to #1) | #1 handles it |
find_baseline_sql() convention path |
(removed; use explicit path or #3) | — |
--no-testcontainers CLI flag |
retained, same name | — |
Items removed entirely:
- Convention-based
baseline.sqldiscovery (src/baseline-sql/baseline.sql). Replaced by explicitpostgres_init_scriptspaths or via #3'sget_baseline_path(). - Hard-coded
bpp-tc-pg/bpp-tc-rediscontainer names. Container naming is an implementation detail of #1's reuse mode (see §14 open question 11 for how reuse-mode worker names should compose). - Docker-compose label scrubbing (
com.docker.compose.*). That's a defensive workaround for users who builtiplweb/bpp_dbserverviadocker compose buildhistorically. Out of scope for a generic plugin; if someone reproduces the bug, fix it in #1 or in their build flow.
Some questions from earlier drafts (about timing — "could fixtures
work?", "could pytest_configure work?") are now resolved in §6 and
no longer listed.
-
Hook collision with future pytest-django versions. If pytest-django ever marks its
pytest_load_initial_conftestshook withtryfirst=Truetoo, both hooks become equally privileged and pluggy falls back to registration order. We should add a regression test that imports pytest-django and asserts our hook runs first by inspecting the resolved hook chain. Question: is there a public pluggy API for that, or do we have to rely on the side effect (env var written before settings imported)? -
Multi-database projects. Django supports multiple
DATABASESentries (default,replica,analytics). v1 supports one. Is there demand for multi-DB? (Probably yes long-term; defer to v2, the config model already extends naturally with a list ofPostgresServices.) -
Settings autodetection. Could we sniff the user's settings file to detect their env-var names automatically? Tempting, fragile. Probably no — we'd parse Python AST, miss django-environ's
env(...)indirection half the time. Lean toward explicit config. -
DATABASE_URLusers. §5.3 punted. If demand is loud, add adatabase_url_envsetting that composespostgres://USER:PASS@HOST:PORT/DBinto a single env var. Easy to add, just deferred. (psycopg3 / async is orthogonal — we just hand backhost:port.) -
Coexistence with
pytest-postgresql. Overlapping scope (it also provides a Postgres). Document: "do not enable both"; add an early-fail check that warns ifpytest_postgresqlis loaded alongside us. -
Settings without django-environ. Settings read directly from
os.environ. Thenskip_dotenv_envis moot — user setsskip_dotenv_env = ""to disable that injection. Worth a docs example. -
CI service-container detection. GitHub Actions / GitLab CI often provide Postgres as a service. Current answer: explicit
disable_env=1in CI; no autodetection magic. -
Reuse-mode container names per worker. #1's reuse mode uses a
<project>-tc-<service>template. For #2's eager-start path, we run a single shared container per service on the controller (workers inherit env). If a future use case needs per-worker eager-started containers, propose<project>-tc-<service>-<worker_id>— defer impl until needed. -
postgres_templatevspostgres_databasesemantics. RESOLVED (user, 2026-05-08): same value is the default; allow them to differ for advanced setups; document the gotcha.postgres_database— name of the DB the container creates at startup (passed asPOSTGRES_DB). This is also the DB into which the official Postgres entrypoint replays/docker-entrypoint-initdb.d/(i.e., wherebaseline.sqllands). Default"postgres".postgres_template— value injected asDATABASES['default']['TEST']['TEMPLATE']in Django settings, causing pytest-django to issueCREATE DATABASE test_<X> WITH TEMPLATE <postgres_template>.
In the typical case they MUST be equal — the template needs to contain the seed data, which means it must be the DB where init scripts ran, which means
postgres_template = postgres_database.The advanced case (different values) is when init scripts themselves create a separate seed DB:
CREATE DATABASE seed_template TEMPLATE template0; \c seed_template -- load fixtures into seed_template
Then
postgres_database = "myapp"(default app DB, can be empty) andpostgres_template = "seed_template".Default behavior: if
postgres_templateis unset andpostgres_init_scriptsis non-empty, defaultpostgres_template = postgres_database(DRY). User can override.Collision concern (template DB locked when a connection is open) is not a real issue in pytest-django flow: setup_databases creates
test_<X>BEFORE any connection opens to the source DB. Django connects totest_<X>from there on, never to the template. Document the gotcha for users running this against a shared/persistent DB (which they shouldn't, but). -
Init-script idempotency on container reuse. Init scripts in
/docker-entrypoint-initdb.d/only run on first Postgres init. If the user changespostgres_init_scriptsand reuses the existing container, the new scripts won't run. Symptom: "I added a fixture SQL but tests don't see it."RESOLVED (user, 2026-05-08): warn-and-continue. When reuse mode is active AND
postgres_init_scriptsis non-empty AND we detect we're attaching to a pre-existing container (not creating fresh), emit one stderr warning at startup:[pytest-testcontainers-django] reuse mode + init_scripts: init scripts NOT replayed against the existing container (Postgres only runs /docker-entrypoint-initdb.d/ on first init). To re-apply: stop and remove the container, then re-run. Suggested: docker rm -f <container-name>Continue normally — tests run against whatever state the existing container has. No hash fingerprinting in v1; hash-check stays deferred as a follow-up if users hit this often. The reasoning is that reuse mode is for fast iteration on a known-good baseline; if you change the baseline you also bump version, which means fresh container, which means scripts run normally.
What this package does not do (and where the responsibility lives). After the architectural split, the boundaries are sharp.
- Maker functions:
make_postgres,make_redis,make_minio, etc. - Docker daemon ping, error message (
DockerNotRunningError). - Image pull, container start, port resolution, readiness wait.
- Named-container reuse logic (when invoked through reuse-aware fixtures or maker arguments).
- Ephemeral cleanup, Ryuk integration.
- Hybrid fixture patterns for users who want session/module/function scoped containers in non-Django contexts.
- Used standalone for non-Django projects (FastAPI, Flask, plain library testing).
- Used alongside #2 for extra services beyond DB+Redis (Elasticsearch, MinIO, Kafka) in Django projects.
pytest_load_initial_conftests(tryfirst=True)hook.- Reading project config from
[tool.pytest-testcontainers-django]pyproject table. register()API for programmatic config.os.environinjection: DB host/port/name/user/password, Redis host/port, TEST TEMPLATE, SKIP_DOTENV.- Init-scripts mount contract (path →
/docker-entrypoint-initdb.d/NN-name.sql). DATABASES['TEST']['TEMPLATE']semantics (env var that settings.py is documented to consume).- xdist worker detection and skip-start behavior.
- pytest-django plugin coexistence (registration order, cleanup ordering on teardown).
--no-testcontainersCLI flag anddisable_envenv var.- atexit safety net.
- Eager-start lifecycle on the controller: start in
pytest_load_initial_conftests, stop inpytest_unconfigure+ atexit.
- Building / refreshing
baseline.sql. - Versioning and validating baseline schema.
pg_dump-based baseline export.pg_restore/psqlpost-CREATE-DATABASE (its monkey-patch mode, if used).- Resolving baseline path via
get_baseline_path() -> Path. - Freshness checks (artifact older than migration set → error).
- #1 → #2: stable maker function signatures (
make_postgres,make_redisreturning handles with.host,.port,.stop()) andDockerNotRunningError. See §3. - #3 → #2:
get_baseline_path() -> Pathhelper. #2 calls it from conftest (Path A) or viause_django_pg_baseline = trueflag (Path B). - #2 → user project: settings.py reads configured env vars at import time and respects the skip-dotenv flag. Canonical TEST TEMPLATE snippet (§10.4) in README.
Breaking any of these contracts → SemVer-major release on the affected packages, coordinated.
| Concern | Owner |
|---|---|
| Docker daemon ping, maker functions, reuse, Ryuk, fixture patterns | #1 |
pytest_load_initial_conftests(tryfirst=True) hook & ordering |
#2 |
os.environ injection (DB, Redis, TEST TEMPLATE, SKIP_DOTENV) |
#2 |
Init-scripts mount contract, --no-testcontainers, xdist worker propagation |
#2 |
Building/refreshing/versioning baseline.sql, get_baseline_path() |
#3 |