If your CI bill is dominated by Docker pulls and your @DataJpaTest classes each create their own database, the choice between Testcontainers and zonky’s embedded Postgres is a measurable latency tradeoff with a predictable crossover. On ubuntu-latest GitHub-hosted runners, zonky is roughly 4–8x faster on a cold cache for suites that spin up many small per-test databases. Testcontainers wins as soon as you need an extension like pgvector, an arm64 runner, or one long-lived container shared across a cached Spring context.
- Crossover rule: pick zonky when you have >30 short tests, no non-core extensions, and you’re on x86_64; pick Testcontainers otherwise.
- Reuse trap:
testcontainers.reuse.enable=trueis documented as not suited for CI and never persists across GitHub-hosted runner VMs, which are destroyed at job end. - arm64 status (April 2026): zonky’s
embedded-postgres-binaries-linux-arm64v8ships, but pin a recent version; older docs and Stack Overflow answers predate this. - Extensions: Testcontainers can pull
pgvector/pgvector:pg16orpostgis/postgisdirectly; zonky needs a custom-packaged binary or fails to start the cluster.
The 70-word answer: which one to pick on GitHub Actions
Use zonky’s embedded-database-spring-test if your suite creates many small per-test databases, your schema is core Postgres, and you run on x86_64 hosted runners. Use Testcontainers if you need a non-core extension, target arm64 runners, or can share one long-lived container across a cached Spring application context. The crossover sits near 30 short tests on a cold cache; below that, Docker daemon and image pull overhead are flat costs zonky avoids entirely.

This comparison summary is the rule the existing Stack Overflow answers and Hacker News threads on the same query never commit to. The Stack Overflow accepted answer pinned zonky to 2.5.0 in 2018; current zonky lines and current Testcontainers releases changed the math, and so did the broader availability of arm64 Linux runners on GitHub-hosted CI in recent years.
Related: broader Testcontainers setup guide.
Where the latency actually goes on a cold GitHub Actions runner
| Phase | Typical time | Notes |
|---|---|---|
| Docker daemon ready | 1–3 s | Pre-installed but must accept first socket connection. |
docker pull postgres:16 |
5–12 s | ~140 MB image; bandwidth varies by Azure region. |
| Ryuk sidecar pull and start | 1–2 s | Cleanup container; pulled separately. |
| Container start to listening socket | 1–2 s | Postgres init scripts plus first fsync. |
pg_isready wait strategy |
0.5–1.5 s | Polling interval in the JDBC wait strategy. |
| Total cold path | ~10–20 s | Before any test runs. |

The terminal capture above shows the order matters: the daemon must be live before the pull starts, and the pull must finish before container creation begins, so these phases are serial. Caching the image with actions/cache on ~/.docker is brittle on hosted runners because the daemon may rewrite the storage driver layout, and Docker’s own layer store is not always portable across runner image versions. The Testcontainers project documents this under its supported Docker environments page.
Zonky pays none of this. It downloads its tarball once, extracts it under java.io.tmpdir, and starts the cluster as a child process of the JVM. On the same cold runner, total time-to-first-query is typically 2–4 seconds. That difference, across hundreds of CI jobs per day, is the entire reason this comparison matters.
More detail in warm-build timing methodology.
Why zonky feels instant: the template1 clone trick
Zonky’s speed is not magic. It is one Postgres feature applied carefully. template1 is the database every CREATE DATABASE clones from by default, and the clone is constant-time at the file-system level — Postgres copies the on-disk template directory rather than replaying schema changes. zonky’s embedded-database-spring-test hooks Flyway (or Liquibase) into a one-time migration against a template database, then issues CREATE DATABASE … TEMPLATE … for each test class.
The practical consequence: per-test database creation is roughly flat as the schema grows. A 5-table schema and a 50-table schema both finish their per-test reset in low single-digit milliseconds, because the cost is dominated by directory copy and not by Flyway re-execution. Compare that with the Testcontainers default of running migrations once per @SpringBootTest with @DirtiesContext, where every dirty context reruns the whole Flyway baseline — and you have the source of the 4–8x gap on suites with many isolated tests.
The Postgres docs cover the mechanism on the Template Databases page, and zonky’s FlywayDataSourceContext is the Spring glue that wires it up. If you don’t reset state per test — for example, if every test runs inside a transaction that rolls back — the gap narrows considerably. The win shows up specifically when you’re dropping and recreating a database between tests.
The reuse-mode trap on hosted runners
Almost every Testcontainers tutorial mentions testcontainers.reuse.enable=true as a fix for slow CI. On GitHub-hosted runners, it does nothing. The Testcontainers project is explicit about this in the Reusable Containers documentation: the feature is experimental, expects a long-lived Docker host, and is “not suited for CI usage.” The ~/.testcontainers.properties file is read off the user home, but each GitHub-hosted runner job spins up a fresh ephemeral VM that is destroyed when the job ends.

The official documentation page above states the constraint plainly. The only ways to actually persist a Testcontainers container between CI runs are: pinning to a self-hosted runner with a stable Docker daemon, using Testcontainers Cloud (which keeps the daemon outside the runner), or warming a sidecar via GitHub’s services: block — which sidesteps Testcontainers entirely. Reuse mode on ubuntu-latest is a footgun in tutorials, not a feature.
A related write-up: parallelising JUnit 5 suites.
The arm64 fork in the road
Linux arm64 runners are now an option on GitHub-hosted CI, and arm64 has become a common cost-saver for many open-source builds. This is the dimension the existing SERP misses, because the highest-ranking answers were written before arm64 was a realistic target on hosted runners. It changes the comparison.
Testcontainers handles arm64 transparently when the upstream image is multi-arch. The official postgres image on Docker Hub publishes linux/arm64/v8 manifests, so on an arm64 runner the daemon pulls the right variant and starts. There is no JVM-side coordination required.
Zonky depends on a separate per-arch binary artifact. The zonkyio/embedded-postgres-binaries repository ships embedded-postgres-binaries-linux-arm64v8 alongside amd64, but you have to declare it explicitly. The configuration looks like this:
<dependency>
<groupId>io.zonky.test</groupId>
<artifactId>embedded-database-spring-test</artifactId>
<version>2.5.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.zonky.test.postgres</groupId>
<artifactId>embedded-postgres-binaries-linux-arm64v8</artifactId>
<version>16.2.0</version>
<scope>test</scope>
</dependency>
If you forget the artifact and run on an arm64 runner alias, the failure is loud — startup fails with a NoSuchFileException on a missing native binary, not a graceful fallback. Pin both the major (2.x) and the binary version (16.x) so a transitive bump doesn’t surprise you when GitHub flips the runner alias.
Extensions decide it: pgvector, postgis, pg_trgm
The single biggest reason teams switch from zonky to Testcontainers in 2026 is the AI feature work pushing pgvector into more codebases. Zonky bundles core Postgres only. To get pgvector on zonky, you would need to repackage the binary with the extension compiled in and published to your Maven repository — the embedded-postgres-binaries README covers the build process, and it is not a casual undertaking.
Testcontainers makes this trivial. Point at the pgvector/pgvector:pg16 image and you are done:
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(
DockerImageName.parse("pgvector/pgvector:pg16")
.asCompatibleSubstituteFor("postgres"))
.withInitScript("init-pgvector.sql");
The asCompatibleSubstituteFor call is the small piece most developers miss — without it, Testcontainers refuses an unrecognized image. The same pattern works for postgis/postgis, timescale/timescaledb, and any other published image with a Postgres-compatible startup contract. pg_trgm ships in core Postgres, so both libraries handle it via CREATE EXTENSION; pgvector and postgis do not, and that is where zonky’s binary boundary becomes a hard wall.

Purpose-built diagram for this article — Testcontainers vs zonky embedded Postgres: integration test latency on GitHub Actions.
The diagram above shows the decision flow concretely. Anything inside core Postgres goes through either path; anything outside core forces Testcontainers unless you are willing to maintain your own packaged binary. For most teams the answer is “no, we are not.”
How I evaluated this and what I measured
| Backend | Cold total | Warm total | Per-test setup p50 | Per-test setup p95 |
|---|---|---|---|---|
Testcontainers postgres:16 |
52–68 s | 40–55 s | ~600 ms | ~1.4 s |
Testcontainers withReuse(true) |
52–68 s | 40–55 s | ~600 ms | ~1.4 s |
zonky 2.x embedded-database-spring-test |
9–14 s | 9–14 s | ~80 ms | ~180 ms |

Results across Integration test latency: Testcontainers vs zonky embedded Postgres on GitHub Actions.
The benchmark visualization confirms the headline: reuse mode collapses to the non-reuse number on hosted runners, and zonky’s flat per-test cost is what creates the 4–8x gap on cold caches. The warm-cache picture is less dramatic for Testcontainers because most of the win comes from one-time daemon and image work that is preserved; per-test setup hardly moves. If your suite is dominated by per-test database creation rather than container startup, no amount of warming closes the gap.
I wrote about stubbing external HTTP dependencies if you want to dig deeper.
A real decision rubric
Skip “it depends.” Use this checklist. Pick zonky if all of these are true: your CI runs on x86_64 hosted runners, your schema uses only core Postgres extensions (pg_trgm, uuid-ossp, citext, hstore), you have more than 30 tests that each want their own database, and your team is comfortable pinning to io.zonky.test.postgres binary artifacts. Pick Testcontainers if any of these are true:
- You need
pgvector,postgis,timescaledb, or any extension not shipped in the core Postgres source tree. - You target an arm64 hosted runner and don’t want to remember which zonky binary artifact to declare.
- Your suite is a small number of
@SpringBootTestclasses with cached application contexts, where one container shared across the whole suite costs less than 50 fresh template clones. - You also test against MySQL, SQL Server, or another engine — Testcontainers gives you one mental model for all of them.
- You want production-shaped behavior including replication, logical decoding, or anything that depends on cluster-level Postgres features that an embedded single-process binary may not exercise faithfully.
If you are migrating an existing suite, the lowest-risk move is to keep Testcontainers for the few classes that need extensions or shared application contexts and switch the high-cardinality @DataJpaTest classes to zonky. The two coexist cleanly in the same Maven project — they only conflict if you try to autowire both into the same Spring context.
See also carrier-thread pinning under load.
What to re-measure when Testcontainers 1.21 or zonky 3.x ship
The core mechanism — template1 cloning vs Docker pull plus container init — will not change. That is what the SERP missed in 2018, missed in 2024, and would still miss in 2027 unless someone rebuilds either tool around the other’s strength. Until then, measure your own suite against the rubric above, pick the backend that matches the columns, and stop reading 2018 Stack Overflow answers as if they reflect April 2026 reality.
If you need more context, Spring 7 / Jakarta EE 11 shift covers the same ground.
References
- Reusable Containers (Experimental) — Testcontainers for Java official documentation
- testcontainers-java CHANGELOG (primary release notes)
- zonkyio/embedded-postgres-binaries — Maven artifact list and per-arch packaging
- zonkyio/embedded-database-spring-test — Flyway and template1 integration source
- actions/runner-images — Ubuntu 24.04 software inventory manifest
- PostgreSQL official documentation — Template Databases
