testcontainers-python: one image tag, a with block, and a reaper for leaks
Testcontainers is a Python library that providing a friendly API to run Docker container. It is designed to create runtime environment to use during your automatic tests.
At a glance
- What is it?
- testcontainers-python turns a Docker image tag into a database fixture that starts and stops inside a context manager, and leans on a separate ryuk container to delete whatever a crashed session leaves behind. The configuration surface is small, the cost is an extra container on every run, and two details in the repository contradict themselves.
- Who is it for?
- Adopt testcontainers-python when your integration tests need a genuine database, a browser or a broker and Docker is already reachable from the machine that runs them. Leave it out for unit tests, for environments without a daemon, and for suites that only need a stand-in object.
- Can I use it commercially?
- Yes. Apache-2.0 is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 14 days ago.
- What is it written in?
- Mainly Python, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 22, 2026, and from our analysis. They are not legal advice.
Editorial analysis
Throwaway containers turn a database fixture into a with block
Integration tests that mock the database only test the mock. When a suite needs a real Postgres, a real browser or a real message broker, somebody on the team ends up maintaining shell scripts that start daemons, wait for ports and clean up after whatever failed. The package description in pyproject.toml calls testcontainers a Python library for throwaway instances of anything that can run in a Docker container, and its stated job is creating a runtime environment to use during automatic tests.
The audience for that trade is narrow. You need a Docker daemon reachable from whatever runs the tests, a laptop with Docker Desktop or a CI runner with a Docker service, and you need to accept that containers start and stop inside the test run itself. In exchange a database fixture stops being a pile of setup functions and becomes one object with a start and a stop, and the container is torn down when the block ends even if an assertion blew up in the middle.
The image tag is the only argument the Postgres fixture needs
The shortest example in the project is a full round trip. One import, a context manager around a Postgres container pinned to an image tag, a connection URL taken from that container, one query, and the server version printed back at you.
>>> from testcontainers.postgres import PostgresContainer
>>> import sqlalchemy
>>> with PostgresContainer("postgres:16") as postgres:
... engine = sqlalchemy.create_engine(postgres.get_connection_url())
... with engine.begin() as connection:
... result = connection.execute(sqlalchemy.text("select version()"))
... version, = result.fetchone()
>>> version
'PostgreSQL 16...'The image tag `postgres:16` decides the server version, and `get_connection_url()` returns a url that `sqlalchemy` consumes directly, so nothing in the snippet needs a host, a port or a password typed by hand. Two absences are worth noticing before you build on this. No host port is named anywhere in the block, so the mapping from a container port to a free host port stays invisible. And no readiness call appears either, which means the wait that makes the version query safe is hidden inside the fixture rather than in code you can read. Both are conveniences, but a suite with its own timing assumptions has to locate them first.
Ryuk reaps abandoned containers, and five variables govern it
A test process that dies with containers still running leaves orphans behind, and nobody notices until the disk fills. Ryuk is the answer to that, a separate watchdog container that deletes dangling containers when the session disappears. It is also the only moving part here with its own configuration surface, and it gets five environment variables.
`TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE` takes the example `/var/run/docker.sock` and is described as the path to Docker's socket used by ryuk. `TESTCONTAINERS_RYUK_PRIVILEGED` has the example `false` and runs ryuk as a privileged container. `TESTCONTAINERS_RYUK_DISABLED`, also `false`, turns ryuk off. `RYUK_CONTAINER_IMAGE` shows `testcontainers/ryuk:0.8.1` for a custom image. `RYUK_RECONNECTION_TIMEOUT`, with the example 10s, is the reconnection timeout for the ryuk TCP socket before ryuk reaps all dangling containers.
Read that list and notice what it leaves out. Nothing in it points the library's own container operations at a remote daemon, and the one socket key is described purely in terms of ryuk. The runtime path is narrower still, since a single attribute is documented outside the environment variable table:
from testcontainers.core import testcontainers_config
testcontainers_config.ryuk_docker_socket = "/home/user/docker.sock"Five settings through the environment, one through a Python object. Disabling ryuk on a shared runner converts the cleanup guarantee into a habit somebody has to keep, and running it privileged changes what a test session is allowed to do on the host.
Installing with uv, the same path the Makefile drives
The getting started section jumps straight into Python code without an install line, so the package name has to come from the project files. pyproject.toml declares the distribution as `testcontainers`, version `4.15.0`, with `requires-python = ">=3.10"`. Classifiers cover Python 3.10, 3.11, 3.12, 3.13 and 3.14 alongside the operating systems Microsoft Windows, POSIX, Unix and MacOS. For running the project's own tests the Makefile is the documented path, and the install target is two uv commands:
uv sync --all-extras
uv run pre-commit installThe contributor container image does the same job differently. It starts from the uv python image, copies the tree and syncs from the lock file:
ARG PYTHON_VERSION=3.10
FROM ghcr.io/astral-sh/uv:python${PYTHON_VERSION}-bookworm-slim
WORKDIR /workspace
COPY . .
RUN uv sync --frozen --all-extras`--all-extras` appears on both paths, so a working environment carries every database integration whether your test needs it or not. The container path adds `--frozen` and stops there, with no pre-commit hook installed inside the image and no lint step, which means linting has to happen on the host. To exercise the result, the Makefile splits the suite in two, running core tests separately from the per-module community tests, and offers a quicker variant that skips tests marked `long_running`:
uv run coverage run --parallel -m pytest -v tests/core
uv run coverage run --parallel -m pytest -v -m "not long_running" tests/core
uv run coverage combineCommunity database modules ship as extras of one distribution
Every module in this project shares one distribution name and one version number. Service specific modules live under optional dependencies in pyproject.toml, so `cassandra = []` exists as an extra name with no package behind it while others pull a driver:
[project.optional-dependencies]
arangodb = ["python-arango>=8"]
aws = ["boto3>=1", "httpx"]
azurite = ["azure-storage-blob>=12"]
cassandra = []
clickhouse = ["clickhouse-driver"]Two details a skim misses there. The aws extra pins `boto3>=1` but leaves `httpx` with no bound at all, so that extra does not resolve the same way twice over time. Elsewhere the bounds are concrete, with `arangodb` asking for `python-arango>=8` and `azurite` for `azure-storage-blob>=12`, while `clickhouse-driver` carries no version at all. And an empty extra such as `cassandra` is not dead weight, it is the hook the test matrix hangs on, because the Makefile derives one test target per directory under `tests/community` from the wildcard in its pattern rule. Adding a module means adding a directory, not publishing a second package.
Core dependencies are `docker`, `urllib3`, `wrapt`, `typing-extensions` and `python-dotenv`. That `docker` entry decides what this library really is, and `python-dotenv` is odd next to a configuration section that covers environment variables plus one attribute: the README does not document where a dotenv file is read from, so teams relying on one for socket settings should check that path themselves.
Sphinx builds the documentation, mkdocs.yml sits at the root
The repository root carries `docs/`, `doctests/`, `.readthedocs.yml` and `mkdocs.yml`. Only one of those documentation stacks is wired to a make target, and it is Sphinx. Both documentation commands run with strict settings:
uv run --all-extras sphinx-build -nW docs docs/_build
uv run --all-extras sphinx-build -b doctest -c doctests docs/core docs/_buildThe -nW flag that appears in both commands treats warnings as errors, so a dangling reference in the docs fails the target instead of printing a note. The second command carries the more interesting detail: it uses `doctests/` as the configuration directory and `docs/core` as the source tree, while the first command reads all of `docs/`. Two targets over one docs directory that never read the same files is exactly the arrangement that lets a broken example sit there unnoticed, since a page under `docs/` that is not inside `docs/core` gets built and never executed.
Meanwhile `mkdocs.yml` sits at the root with no make target invoking it, and the top level `scripts/` directory has no target either. If you want to reproduce what readers see, the two sphinx commands above are the ones that exist; nothing in the Makefile builds the mkdocs configuration.
The codecov badge points at master while the branch is main
A close reading of the badge block at the top of the README finds a stale branch. The codecov badge renders from a `branch/master` path while the repository's default branch is `main`, so on a repository that no longer publishes `master` that badge cannot reflect current coverage. The two Actions badges beside it, for `ci-core.yml` and `ci-community.yml`, carry no branch segment at all, which is why the slip is easy to miss: one badge out of three hardcodes a branch name the project does not use. Underneath, `codecov.yml` at the root and a coverage target that emits xml and html are both still wired up, so the reporting side works while the badge shows nothing.
The version story is consistent across files, which is what makes the badge detail stand out as a separate problem. `pyproject.toml` carries `4.15.0` with an inline comment saying the value is auto-incremented by release-please, and that matches the newest published version dated 2026-07-24, while the last push to main was on 2026-09-14, so the default branch carries work newer than that version with no bump on it. The distribution is Apache-2.0 and ships `LICENSE.txt` next to the package metadata, which means code copied out of these examples into your own repository keeps the same obligations as the library.
Driving the docker SDK by hand, and what the wrapper adds
The closest alternative is not a competing library but the layer underneath. `docker` already sits in the core dependencies, and a team can write its own fixture around that SDK: start a container, poll for readiness, tear it down in teardown. The difference in approach is narrow and worth stating plainly. A hand written fixture has no watchdog, so a crashed test process leaves its container running until somebody notices, and that is exactly the gap ryuk fills. What the wrapper adds is ownership: a context manager that stops what it started, a reaper for the case where the process cannot, and per service modules that hand back a ready connection URL such as `get_connection_url()`.
What it costs is concrete too. Every run starts a second container, `testcontainers/ryuk:0.8.1` in the documented example, which is one more container to schedule on a busy CI runner. Note that the table gives that tag as an example rather than as a fixed default, and nothing in the project files states which image the library selects when `RYUK_CONTAINER_IMAGE` is unset. Finally, no part of the getting started example shows how to share one container across test cases, so wrapping each case in its own block pays a container start every time, while moving it to module scope gives up the per test isolation the context manager was built to provide.
Editorial conclusion
Adopt testcontainers-python when your integration tests need a genuine database, a browser or a broker and Docker is already reachable from the machine that runs them. Leave it out for unit tests, for environments without a daemon, and for suites that only need a stand-in object. Before committing, check three things: whether a fresh container per test fits your runtime budget, whether your CI runner exposes the socket path that the ryuk variables expect, and whether the extra list in pyproject.toml actually covers your service.
Frequently asked questions
How do I install testcontainers-python with only the database drivers I need?
The project publishes one distribution, `testcontainers`, and service specific modules live under optional dependencies in pyproject.toml, with entries such as `arangodb = ["python-arango>=8"]`, `clickhouse = ["clickhouse-driver"]` and `cassandra = []`, which exists as an extra name with no package behind it. The README does not show an install command, so the extras list in pyproject.toml is the place to check which modules pull a driver.
How do I point testcontainers-python at a Docker socket other than the default?
Set `TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE` to the socket path, documented with the example `/var/run/docker.sock`, or assign `testcontainers_config.ryuk_docker_socket` during the run, documented with the value `/home/user/docker.sock`. The first is described as the path to Docker's socket used by ryuk.
Can I stop ryuk from deleting my containers during a test run?
`TESTCONTAINERS_RYUK_DISABLED` has the example `false` and disables ryuk, `TESTCONTAINERS_RYUK_PRIVILEGED` runs ryuk as a privileged container, and `RYUK_CONTAINER_IMAGE` selects an image such as `testcontainers/ryuk:0.8.1`. Turning it off leaves cleanup to the test code itself.
Does testcontainers-python support Windows and macOS?
The classifiers in pyproject.toml list Microsoft Windows, POSIX, Unix and MacOS, and the package requires Python 3.10 or newer with classifiers running from 3.10 through 3.14. A Docker daemon still has to be reachable wherever the tests run.
How do I run only the fast tests in the testcontainers-python repository?
The Makefile target quick-core-tests runs `uv run coverage run --parallel -m pytest -v -m "not long_running" tests/core`, which skips tests marked long_running and stays inside the core test directory, leaving the per module community tests to their own targets.
What happens to my containers if the test process crashes?
Ryuk exists for that case and reaps dangling containers, with `RYUK_RECONNECTION_TIMEOUT`, documented with the example 10s, set to the reconnection timeout for the ryuk TCP socket before ryuk reaps all dangling containers.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/testcontainers-testcontainers-python)