@hackage / ephemeral-pg

Temporary PostgreSQL databases for testing

Latest0.2.2.0

About

Metadata

  • Last updated , by shinzui
  • License BSD-3-Clause
  • Categories Databases, Testing
  • Maintained by: Nadeem Bitar

  • Lottery factor: 1

Links

Installation

Readme

ephemeral-pg

A modern Haskell library for creating temporary PostgreSQL databases for testing.

Features

  • Native hasql integration
  • initdb caching for fast startup
  • Copy-on-write support (macOS/Linux)
  • Filesystem snapshots
  • No shell injection vulnerabilities
  • Type-safe configuration

Requirements

  • GHC 9.6+
  • PostgreSQL 14+

Installation

Add to your cabal file:

build-depends:
  ephemeral-pg

Or with Stack, add to your package.yaml:

dependencies:
  - ephemeral-pg

Quick Start

import EphemeralPg qualified as Pg
import Hasql.Connection qualified as Connection

main :: IO ()
main = do
  result <- Pg.with \db -> do
    Right conn <- Connection.acquire (Pg.connectionSettings db)
    -- Use the connection...
    Connection.release conn
  case result of
    Left err -> putStrLn $ "Error: " <> Pg.renderStartError err
    Right () -> putStrLn "Success!"

Usage Examples

Basic Usage

The simplest way to use ephemeral-pg is with the with function, which creates a temporary database, runs your action, and cleans up automatically:

import EphemeralPg qualified as Pg
import Hasql.Connection qualified as Connection
import Hasql.Session qualified as Session
import Hasql.Statement qualified as Statement

testQuery :: IO ()
testQuery = do
  result <- Pg.with \db -> do
    Right conn <- Connection.acquire (Pg.connectionSettings db)

    -- Run a simple query
    result <- Session.run (Session.statement () selectOne) conn

    Connection.release conn
    pure result

  case result of
    Left err -> putStrLn $ "Startup error: " <> Pg.renderStartError err
    Right (Left sessionErr) -> putStrLn $ "Query error: " <> show sessionErr
    Right (Right value) -> putStrLn $ "Result: " <> show value
  where
    selectOne = Statement.Statement "SELECT 1" mempty decoder True
    decoder = Decoders.singleRow (Decoders.column (Decoders.nonNullable Decoders.int4))
Custom Configuration

You can customize the database configuration:

import EphemeralPg qualified as Pg
import EphemeralPg.Config qualified as Config

customTest :: IO ()
customTest = do
  let config = Pg.defaultConfig
        { Config.databaseName = "mytest"
        , Config.postgresSettings =
            [ ("log_statement", "'all'")
            , ("log_min_duration_statement", "0")
            ]
        }

  result <- Pg.withConfig config \db -> do
    -- Database name is "mytest"
    -- All statements are logged
    pure ()

  case result of
    Left err -> putStrLn $ Pg.renderStartError err
    Right () -> putStrLn "Done!"
Using Verbose Configuration

For debugging, use the pre-configured verbose settings:

import EphemeralPg qualified as Pg

debugTest :: IO ()
debugTest = do
  result <- Pg.withConfig Pg.verboseConfig \db -> do
    -- All statements logged with duration
    pure ()
  pure ()
Using auto_explain

For query plan analysis:

import EphemeralPg qualified as Pg

analyzeQueries :: IO ()
analyzeQueries = do
  result <- Pg.withConfig Pg.autoExplainConfig \db -> do
    -- Query plans automatically logged for slow queries
    pure ()
  pure ()
Caching for Fast Startup

For faster test suite execution, use caching. The first run initializes the cache, and subsequent runs copy from it (using CoW if available):

import EphemeralPg qualified as Pg

cachedTest :: IO ()
cachedTest = do
  -- First call: ~2s (runs initdb and caches result)
  -- Subsequent calls: ~200ms (copies from cache)
  result <- Pg.withCached \db -> do
    -- Use the database...
    pure ()
  pure ()
Suite-level fixtures with template databases

For larger integration suites where many examples need the same migrated schema, consider starting one cached PostgreSQL server for the whole suite, migrating a template database once, and cloning clean per-example databases with PostgreSQL's CREATE DATABASE ... TEMPLATE ....

See Suite-level template databases for the full pattern, tradeoffs, and a complete fixture implementation.

Manual Lifecycle Management

For more control, use start and stop directly:

import EphemeralPg qualified as Pg
import Control.Exception (bracket)

manualLifecycle :: IO ()
manualLifecycle = do
  result <- Pg.start Pg.defaultConfig
  case result of
    Left err -> putStrLn $ Pg.renderStartError err
    Right db -> do
      -- Use the database...
      putStrLn $ "Database running on port: " <> show db.port

      -- Clean up when done
      Pg.stop db

Or with bracket for exception safety:

import EphemeralPg qualified as Pg
import Control.Exception (bracket)

bracketExample :: IO ()
bracketExample = do
  result <- Pg.start Pg.defaultConfig
  case result of
    Left err -> putStrLn $ Pg.renderStartError err
    Right db ->
      bracket (pure db) Pg.stop \db' -> do
        -- Use the database...
        pure ()
Restarting the Database

You can restart the database to apply configuration changes or test recovery:

import EphemeralPg qualified as Pg

restartTest :: IO ()
restartTest = do
  result <- Pg.with \db -> do
    let port1 = db.port

    -- Restart the server
    restartResult <- Pg.restart db
    case restartResult of
      Left err -> fail $ "Restart failed: " <> show err
      Right db' -> do
        -- Server restarted, data preserved
        let port2 = db'.port
        -- Port remains the same
        pure (port1 == port2)

  case result of
    Left err -> putStrLn $ Pg.renderStartError err
    Right same -> putStrLn $ "Ports same: " <> show same
Snapshots

Create filesystem snapshots for fast test isolation:

import EphemeralPg qualified as Pg
import EphemeralPg.Snapshot qualified as Snapshot

snapshotTest :: IO ()
snapshotTest = do
  result <- Pg.with \db -> do
    -- Set up initial state
    -- ... create tables, insert data ...

    -- Create a snapshot
    Right snapshot <- Snapshot.createSnapshot db

    -- Run a destructive test
    -- ... delete everything ...

    -- Restore to the snapshot
    Right () <- Snapshot.restoreSnapshot snapshot db

    -- Data is restored

    -- Clean up snapshot when done
    Snapshot.deleteSnapshot snapshot

    pure ()

  pure ()
Dump and Restore

Export and import database contents:

import EphemeralPg qualified as Pg
import EphemeralPg.Dump qualified as Dump

dumpTest :: IO ()
dumpTest = do
  result <- Pg.with \db -> do
    -- Set up data
    -- ...

    -- Dump to file
    Right () <- Dump.dump db "/tmp/backup.sql"

    pure ()

  -- Later, restore to a new database
  result2 <- Pg.with \db2 -> do
    Right () <- Dump.restore db2 "/tmp/backup.sql"
    -- Data is now in db2
    pure ()

  pure ()

Configuration Reference

Config Fields
Field Type Default Description
databaseName Text "postgres" Name of the database to create
user Text Current user PostgreSQL username
password Maybe Text Nothing PostgreSQL password (Nothing = trust auth)
port Last Word16 Auto-assigned Port number (finds free port if not specified)
dataDirectory DirectoryConfig Temporary Data directory location
socketDirectory DirectoryConfig Temporary Unix socket directory
temporaryRoot Last FilePath System temp Root for temporary directories
postgresSettings [(Text, Text)] Optimized defaults postgresql.conf settings
initDbArgs [Text] [] Additional initdb arguments
Pre-configured Configs
  • defaultConfig - Basic configuration with optimized defaults
  • verboseConfig - All statements logged with duration
  • autoExplainConfig - Query plans logged for slow queries
PostgreSQL Settings Defaults

The default configuration includes these optimized settings for testing:

shared_buffers = 12MB
fsync = off
synchronous_commit = off
full_page_writes = off
log_min_duration_statement = 0
log_connections = on
log_disconnections = on
random_page_cost = 1.0

API Reference

Core Functions
-- Bracket-style (recommended)
with :: (Database -> IO a) -> IO (Either StartError a)
withConfig :: Config -> (Database -> IO a) -> IO (Either StartError a)

-- With caching
withCached :: (Database -> IO a) -> IO (Either StartError a)

-- Manual lifecycle
start :: Config -> IO (Either StartError Database)
stop :: Database -> IO ()
restart :: Database -> IO (Either StartError Database)
Database Handle

Access database fields using dot syntax (requires OverloadedRecordDot):

db.port             :: Word16       -- Port number
db.databaseName     :: Text         -- Database name
db.user             :: Text         -- Username
db.dataDirectory    :: FilePath     -- Data directory path
db.socketDirectory  :: FilePath     -- Socket directory path

Computed connection helpers:

connectionSettings :: Database -> Settings  -- hasql Settings
connectionString :: Database -> Text         -- libpq connection string
Cache Management
clearCache :: IO ()      -- Clear current user's cache
clearAllCaches :: IO ()  -- Clear all cached data

Benchmarks

Measured with tasty-bench in wall-clock mode on Apple Silicon.

Benchmark Time vs baseline
Lifecycle
defaultConfig 796 ms --
defaultConfig <> verboseConfig 812 ms 1.02x
defaultConfig <> autoExplainConfig 100 769 ms 0.97x
Caching
withConfig (uncached) 755 ms --
withCached 434 ms 0.58x
Snapshot
lifecycle only 761 ms --
lifecycle + createSnapshot 1.13 s 1.48x
lifecycle + createSnapshot + restoreSnapshot 1.39 s 1.83x
Connection
hasql acquire + release 1.64 ms --

Highlights:

  • Cached startup is ~42% faster than uncached (CoW copy vs full initdb)
  • verboseConfig and autoExplainConfig add negligible overhead
  • Snapshot create adds ~365 ms, restore adds ~265 ms
  • Connection acquire/release is ~1.6 ms once the database is running

Comparison with tmp-postgres

ephemeral-pg is a modern replacement for tmp-postgres, addressing several issues:

Feature tmp-postgres ephemeral-pg
Shell injection Vulnerable Safe (typed-process)
Windows support Partial Unix-only (explicit)
Socket path length Can fail Validated
GHC version 8.0+ 9.6+
hasql integration Via options Native
initdb caching Yes Yes (improved)
Copy-on-write Yes Yes
PostgreSQL version 9.3+ 14+

Troubleshooting

"initdb not found"

Ensure PostgreSQL binaries are in your PATH:

# macOS (Homebrew)
export PATH="/opt/homebrew/opt/postgresql@14/bin:$PATH"

# Linux (Debian/Ubuntu)
export PATH="/usr/lib/postgresql/14/bin:$PATH"
Socket path too long

Unix sockets have a path length limit (~104 bytes). ephemeral-pg uses short paths to avoid this, but if you specify a custom socket directory, ensure the full path is under 90 characters.

Permission denied

Ensure you have write access to the temporary directory. By default, the system temp directory is used.

Tracing

OpenTelemetry tracing is available through the optional companion package ephemeral-pg-opentelemetry, which lives next to this library in the same repository. It mirrors the EphemeralPg lifecycle API and emits one span per public operation:

Wrapper Span name
withTraced ephemeralpg.with
withCachedTraced ephemeralpg.with_cached
startTraced ephemeralpg.start
stopTraced ephemeralpg.stop
restartTraced ephemeralpg.restart
createSnapshotTraced ephemeralpg.snapshot.create
restoreSnapshotTraced ephemeralpg.snapshot.restore
deleteSnapshotTraced ephemeralpg.snapshot.delete
dumpTraced ephemeralpg.dump
restoreTraced ephemeralpg.restore

Each span carries the standard database attributes (db.system.name/db.system, db.namespace/db.name) plus library-specific ones (ephemeralpg.port, ephemeralpg.shutdown.mode). Attribute name selection obeys OTEL_SEMCONV_STABILITY_OPT_IN exactly the way upstream HTTP instrumentation does — set it to http for stable names, http/dup for both stable and legacy. Errors from EphemeralPg.start are recorded uniformly with error.type (constructor name), span status Error, and a recordException event.

The wrappers are designed to nest under a parent test span. Combined with hs-opentelemetry-instrumentation-hspec, a test that calls withTraced produces a tree of the form:

Run tests
  └─ ephemeral-pg under OpenTelemetry
     └─ emits a span tree under withTraced
        └─ ephemeralpg.with
           ├─ ephemeralpg.start
           ├─ ephemeralpg.with.body
           └─ ephemeralpg.stop

Add the dependency to your test-suite (the package is not yet on Hackage; reference it via the local cabal.project):

build-depends:
  ephemeral-pg-opentelemetry,
  hs-opentelemetry-api,
  hs-opentelemetry-sdk,
  hs-opentelemetry-instrumentation-hspec,

See ephemeral-pg-opentelemetry/test/Demo.hs for a runnable example.

License

BSD-3-Clause