Self-hosted service
opentable/otj-pg-embedded avatar
opentable/otj-pg-embedded

otj-pg-embedded: real Postgres in a container for Java unit tests

Java embedded PostgreSQL component for testing

718 stars193 forksJavaApache-2.0

At a glance

What is it?
otj-pg-embedded runs a real PostgreSQL instance inside a Docker container from a JUnit test, so a Java project can test against the actual database rather than an in-memory substitute. It suits teams whose SQL and constraints carry real behaviour, and it is a poor fit for a suite that has to finish in seconds on a laptop with no Docker.
Who is it for?
Adopt otj-pg-embedded when your persistence layer depends on PostgreSQL specifics that an in-memory substitute does not reproduce, and when your build environment already runs Docker, since the library starts a real container per test class or per test. Do not adopt it for a suite that must stay fast or that has to run on a machine with no container runtime.
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 38 days ago.
What is it written in?
Mainly Java, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

The problem it solves: SQL that only misbehaves against real Postgres

The stated purpose of the library is to allow embedding PostgreSQL into Java application code using Docker containers, and the readme calls it excellent for unit testing with a real Postgres without requiring end users to install and set up a database cluster.

That last clause is the interesting one. The audience is not just developers with a laptop. It is everyone downstream of the developer, including the people who run your build in a CI system and the people who consume a test fixture. A test suite that needs a database should not require the person running it to have provisioned one.

The problem being solved is specific. Most Java projects test persistence against an in-memory database, and an in-memory database is a different database. It does not have the same type coercions, the same constraint behaviour, the same default isolation level, the same handling of a null in a unique index, or the same planner. A test suite built on a substitute passes, and then the application fails in production on a behaviour the substitute never implemented. This library removes the substitute.

The cost of that fidelity is speed, and the readme is upfront about it. Running a container based instance is 2 to 5 times slower than the tarball approach the library used before version 1.0, and that adds up with many tests. The suggested workaround is to change the lifecycle, but the readme immediately notes the trap: tests that modify the same tables then become possibly non reproducible.

So this is a deliberate trade. You get a real database and pay for it in wall clock time and in a container dependency.

The JUnit Jupiter extension and the random port problem

The primary integration is a JUnit Jupiter extension, and the registration is two lines:

java
@RegisterExtension
static final SingleInstancePostgresExtension pg = EmbeddedPostgresExtension.singleInstance();

That registers an instance of EmbeddedPostgres with JUnit, so JUnit manages the start and stop. You then obtain a DataSource from the instance, and the readme names the call as pg.getEmbeddedPostgres().getPostgresDatabase().

You can also use the EmbeddedPostgres class directly, starting and stopping it yourself, and the readme points at the EmbeddedPostgresTest class for an example. The direct usage shape is short:

java
public void testDatabaseName() throws IOException,SQLException{
        EmbeddedPostgres db=EmbeddedPostgres.builder().start();
        Datasource dataSource = db.getPostgresDatabase();
        .... use the datasource then ...
        db.close();
        }

The builder carries options to set the image, the tag, the database name, and various configuration options, and the readme also mentions options for the initDB configuration parameters, the bind mounts and the network.

One detail deserves emphasis because it is the kind of thing that produces a flaky test. The port exposed on the host is random and ephemeral, and the readme is explicit that you should always use the getDatasource and getUrl methods. A hardcoded jdbc URL with a fixed port is a bug waiting to happen, and with a random port per instance it is a bug that will happen when two tests run in parallel.

The default credentials are postgres/postgres and the default database is postgres, which is fine for a test fixture and worth changing if a test asserts on the connection string.

Flyway and Liquibase preparers create an isolated database per test class

The most valuable feature for a real application is schema migration support, because the schema in a test is usually the schema the application expects, applied by the same tool that applies it in production.

Flyway is integrated through a preparer that loads from a classpath location:

java
@RegisterExtension
static final PreparedDbExtension db =
    EmbeddedPostgresExtension.preparedDatabase(
        FlywayPreparer.forClasspathLocation("db/my-db-schema"));

Liquibase has the parallel shape with its own preparer:

java
@RegisterExtension
static final PreparedDbExtension db =
    EmbeddedPostgresExtension.preparedDatabase(
            LiquibasePreparer.forClasspathLocation("liqui/master.xml"));

What this produces is described as an independent database for the test class with the given schema loaded from the classpath. The readme explains the mechanism, and it is the reason this approach is fast despite doing real work: database templates are used, so the time cost is relatively small, given the isolation that truly independent databases give you.

That is the key trade. A naive implementation would run the migration on every test class, which is slow. Templates let the server clone a prepared database, which is fast and gives each test class its own isolated copy with no cross-test contamination.

One warning is attached to the Flyway path. Recent versions of Flyway will probably hang if you have concurrent indexing, and the readme points at the 1.0.3 changelog for the features that disable the broken lock feature, with the FlywayPreparerTest as the reference. If your test suite creates indexes concurrently, read that changelog entry before you blame the library.

Choosing the Postgres image, and the 17-alpine default

The default is to pull a tag from the Docker Hub registry, hardcoded in the EmbeddedPostgres class, and it is currently 17-alpine. The readme says this fits the needs of OpenTable and that you can change it easily, which is super useful both to move to a newer Postgres version and to build your own Dockerfile with additional extensions.

There are three ways to change it, in a defined order of precedence.

First, if PG_FULL_IMAGE is set, it is used and is assumed to include the full docker image name, for example something like docker.otenv.com/postgres:mytag. Second, otherwise if TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX is set, that is prefixed to postgres, adding a slash if it does not already exist, for example docker.otenv.com/. Third, otherwise the default is used as defined above.

The explicit builder route also works:

java
    EmbeddedPostgres.builder()
        .setTag("10")
        .start();

and a custom image can be set directly:

java
    EmbeddedPostgres.builder()
        .setImage(DockerImageName.parse("docker.otenv.com/super-postgres"))
        .start();

The TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX variable is a sign of the intended deployment: the library fits the Testcontainers world, where a registry prefix is how a corporate mirror is selected. The topics list also names testcontainers and docker, and the reason a prefix variable exists is so that a company with its own registry can serve the Postgres image without changing the code. Note that the Postgres version and the library version are independent, which was one of the reasons for the switch to containers, since you can move the tag without waiting for a new library release.

The move from tarball to container and what it cost

The readme dedicates a section to the reasons behind the 1.0 rewrite, and it is the most useful part of the document for anyone evaluating the library.

Before 1.0 the library used an embedded tarball, and that was extremely fast, which the readme calls a major plus. The switch to Docker was made for five reasons. Multi architecture support became a huge issue with the introduction of the Mac M1, along with Windows ARM and Linux ARM. The same container works the same way on every OS, Mac, Windows and Linux, and the official Postgres Docker image can be used by default. A tarball is needed for every Linux distribution, because PG 10 and later no longer ship a universal binary for Linux, which means a lot of support and maintenance work that the maintainers did not have time for. It is easy to switch the Docker image tag to upgrade versions with no need for a whole new library release. And it is more maintainable and more secure, since you pull Docker images you trust rather than trusting the maintainers' tarballs running in your security context. There is also the option to build an image yourself from the official Postgres image with extra extensions and setup scripts.

The disadvantages are listed with equal candour. It is 2 to 5 times slower than running a tarball. Some API compatibility changes and options have probably disappeared, and the readme invites pull requests. And Docker in Docker can be dodgy to get running.

That last point is the one that bites in CI. A build agent that itself runs in a container needs the Docker socket wired through, and the readme notes it can be tricky. That is a deployment decision for your CI team, not something the library can hide.

The support matrix, the alternatives and the JUnit 4 removal

Two sections of the readme are essentially the project's policy, and they are worth reading before you commit.

The first is the platform stance. OpenTable primarily uses Macs and Ubuntu Linux, and the maintainers will help with other platforms where they can, but other platforms such as Windows depend primarily on community support because the maintainers do not have the time or the hardware. They are happy to merge pull requests, though. The same honesty extends to container runtime substitutes: the readme asks that if you use podman or lima rather than true Docker you say so, and notes that the Docker socket API those tools provide is not 100 percent compatible. They have managed to get Podman working, albeit not 100 percent reliably.

The second is the pre-1.0 policy. No further pull requests or tickets will be accepted for the pre 1.0.0 release unless community support arises for the legacy branch, and any pull requests for pre 1.x should target that legacy branch. The last pure Java 11 release was 1.0.3, and the note explains that Flyway 10 requires Java 17 even though the library itself requires Java 11 or later.

The readme also points at an Alternatives Considered section, acknowledging that there are other approaches that may fit your needs better. What those alternatives are is not described in the repository text, so that comparison is something you will have to make yourself against the library's own framing: a real database in a container, at 2 to 5 times the cost of a tarball.

One breaking change is easy to miss. The library supports JUnit Jupiter extensions, and the JUnit 4 rules were removed in version 1.1.2. A team upgrading from 1.1.1 or earlier has to port its rule based tests.

Maintenance state and what the Apache-2.0 licence covers

The repository is not archived, and the last push was on 2026-09-16. The three most recent tags are otj-pg-embedded-1.1.3, 1.1.2 and 1.1.1, all on 2026-09-16, with 1.1.2 and 1.1.1 published within a minute of each other, which looks like a packaging fix followed by a re-publish rather than three separate features.

The repository layout is a single Maven module with a pom.xml at the root, a src/ directory, a CHANGELOG.md and a CONTRIBUTING.md, and the LICENSE.txt carrying the Apache-2.0 terms. The group is com.opentable, and the package names follow com.opentable.db.postgres.embedded, so the artifact is not a generic community project but an OpenTable component that happens to be published for general use.

The licence is the permissive end of the scale. Apache-2.0 places no copyleft obligation on code that depends on this library, which matters for a test scoped dependency that many teams already run inside a commercial build. The standard conditions still apply, chiefly the requirement to preserve the copyright and licence notices when redistributing the library itself, and the patent grant and termination clauses.

The version line deserves one practical note. The library sits at 1.1.x with a CHANGELOG.md in the repository, so the upgrade path is a normal minor version bump, but the 1.1.2 change, which removed the JUnit 4 rules, is the kind of removal that breaks a build rather than degrades it. Read the changelog for the version you are moving to before you move to it.

Editorial conclusion

Adopt otj-pg-embedded when your persistence layer depends on PostgreSQL specifics that an in-memory substitute does not reproduce, and when your build environment already runs Docker, since the library starts a real container per test class or per test. Do not adopt it for a suite that must stay fast or that has to run on a machine with no container runtime. Before you start, confirm the container runtime works on the host, note that the default image tag is 17-alpine and that the exposed port is random, so read the DataSource from the instance rather than hardcoding a URL, and check the Java version your build uses against the 11 requirement, since Flyway 10 needs 17.

Frequently asked questions

How do I start an embedded Postgres in a JUnit Jupiter test?

Register the extension with @RegisterExtension as a static field initialised with EmbeddedPostgresExtension.singleInstance(), which lets JUnit manage the start and stop. You then get a DataSource from pg.getEmbeddedPostgres().getPostgresDatabase().

Which Postgres version does otj-pg-embedded use and can I change it?

The default tag is hardcoded in the EmbeddedPostgres class and is currently 17-alpine, pulled from the Docker Hub registry. You can override it with the PG_FULL_IMAGE or TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX environment variables, or with setTag and setImage on the builder.

How do I load a Flyway or Liquibase schema into the test database?

Use EmbeddedPostgresExtension.preparedDatabase with FlywayPreparer.forClasspathLocation or LiquibasePreparer.forClasspathLocation. This creates an independent database for the test class with the schema loaded from the classpath, using database templates to keep the time cost small.

What port does the embedded Postgres listen on?

The port exposed on the host is random and ephemeral, so the readme says to always use the getDatasource and getUrl methods rather than hardcoding a connection URL. The default credentials are postgres/postgres and the default database is postgres.

Does otj-pg-embedded still support JUnit 4?

No. The library supports JUnit Jupiter extensions, and the JUnit 4 rules were removed in version 1.1.2. The library requires Java 11 or later, though Flyway 10 itself requires Java 17, and the last pure Java 11 release was 1.0.3.

Official sources

  1. Issues
  2. License: Apache-2.0
  3. opentable/otj-pg-embedded on GitHub
  4. README
  5. Releases
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.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/opentable-otj-pg-embedded.svg)](https://hysenlabs.com/projects/opentable-otj-pg-embedded)

Was this useful?