Observability

Version 0.5.2 Updated Jul 20, 2026

Set RUST_LOG=pg_embed::observability=info to emit tracing spans that describe privilege drops, directory ownership or permission updates, scoped environment application, and the postgresql_embedded setup/start/stop lifecycle. The log target keeps sensitive values redacted: environment changes are rendered as KEY=set or KEY=unset, and PostgreSQL settings avoid echoing passwords. Enable RUST_LOG=pg_embed::observability=debug to surface a sanitized snapshot of the prepared settings, including the version requirement, host and port, installation and data directories, and the .pgpass location. Passwords log as <redacted> and configuration entries are reduced to their keys, so secrets stay out of the debug stream, even when bootstrap fails early. Subscribers that record span enter/exit events, for example via FmtSpan::ENTER|CLOSE, can reconstruct the lifecycle flow without needing additional instrumentation in downstream crates.

Environment change summaries are truncated once they exceed roughly 512 characters, while the change count is always recorded. Lifecycle failures now emit at error level, so log streams can distinguish genuine errors from the normal informational lifecycle noise.

Using the `rstest` fixture

pg_embedded_setup_unpriv::test_support::test_cluster exposes an rstest fixture that constructs the RAII guard on demand. Import the fixture so it is in scope and declare a test_cluster: TestCluster parameter inside an #[rstest] function; the macro injects the running cluster automatically. The test_cluster and shared_test_cluster fixtures are synchronous and constructed via TestCluster::new(), so they must not be used in async tests; TestCluster::start_async() should be used for async tests.

use pg_embedded_setup_unpriv::{test_support::test_cluster, TestCluster};
use rstest::rstest;

#[rstest]
fn runs_migrations(test_cluster: TestCluster) {
    let metadata = test_cluster.connection().metadata();
    assert!(metadata.port() > 0);
}

The fixture integrates with rstest-bdd, a Behaviour-Driven Development (BDD) crate, so behaviour tests can remain declarative as well:

use pg_embedded_setup_unpriv::{test_support::test_cluster, TestCluster};
use rstest_bdd_macros::scenario;

#[scenario(path = "tests/features/test_cluster_fixture.feature", index = 0)]
fn coverage(test_cluster: TestCluster) {
    let _ = test_cluster.environment();
}

If PostgreSQL cannot start, the fixture panics with a SKIP-TEST-CLUSTER-prefixed message that retains the original error. Unit tests fail immediately, while behaviour tests can convert known transient conditions into soft skips via the shared skip_message helper.

Shared cluster fixture for fast test isolation

When test execution time is critical, use the shared_test_cluster fixture instead of test_cluster. The shared fixture initializes a single PostgreSQL cluster on first access and reuses it across all tests in the same binary, eliminating per-test bootstrap overhead.

use pg_embedded_setup_unpriv::{test_support::shared_test_cluster, TestCluster};
use rstest::rstest;
use std::sync::atomic::{AtomicUsize, Ordering};

static DB_COUNTER: AtomicUsize = AtomicUsize::new(0);

fn unique_database_name() -> String {
    let id = DB_COUNTER.fetch_add(1, Ordering::Relaxed);
    format!("shared_cluster_test_{}_{id}", std::process::id())
}

#[rstest]
fn uses_shared_cluster(shared_test_cluster: &'static TestCluster) {
    let db_name = unique_database_name();

    // Create a per-test database for isolation.
    shared_test_cluster.create_database(&db_name).unwrap();

    // Run tests against the database.
    let url = shared_test_cluster.connection().database_url(&db_name);
    assert!(url.contains(&db_name));

    // Clean up the per-test database.
    shared_test_cluster.drop_database(&db_name).unwrap();
}

For programmatic access without rstest, use shared_cluster() directly:

use pg_embedded_setup_unpriv::test_support::shared_cluster;

# fn main() -> pg_embedded_setup_unpriv::BootstrapResult<()> {
let cluster = shared_cluster()?;

// Multiple calls return the same instance
let cluster2 = shared_cluster()?;
assert!(std::ptr::eq(cluster, cluster2));
# Ok(())
# }

When to use each fixture:

Fixture Use case
test_cluster Tests that modify cluster-level settings or state
shared_test_cluster Tests that only need database-level isolation

Table 2: Fixture selection for pg-embed-setup-unpriv test clusters.

The shared cluster is particularly effective when combined with template databases (see "Database lifecycle management" below) to reduce per-test overhead from seconds to milliseconds.

Connection helpers and Diesel integration

TestCluster::connection() exposes TestClusterConnection, a lightweight view over the running cluster's connection metadata. Use it to read the host, port, superuser name, generated password, or the .pgpass path without cloning the entire bootstrap struct. When you need to persist those values beyond the guard you can call metadata() to obtain an owned ConnectionMetadata.

Enable the diesel-support feature to call diesel_connection() and obtain a ready-to-use diesel::PgConnection. The default feature set keeps Diesel optional for consumers.

use diesel::prelude::*;
use pg_embedded_setup_unpriv::TestCluster;

# fn main() -> pg_embedded_setup_unpriv::BootstrapResult<()> {
let cluster = TestCluster::new()?;
let connection = cluster.connection();
let url = connection.database_url("postgres");
assert!(url.starts_with("postgresql://"));

#[cfg(feature = "diesel-support")]
{
    let mut diesel_conn = connection.diesel_connection("postgres")?;
    #[derive(QueryableByName)]
    struct ValueRow {
        #[diesel(sql_type = diesel::sql_types::Integer)]
        value: i32,
    }

    let rows: Vec<ValueRow> = diesel::sql_query("SELECT 1 AS value")
        .load(&mut diesel_conn)?;
    assert_eq!(rows[0].value, 1);
}
# Ok(())
# }

Database lifecycle management

TestClusterConnection provides methods for programmatically creating and dropping databases on the running cluster. These are useful for test isolation patterns where each test creates its own database to avoid cross-test interference.

use pg_embedded_setup_unpriv::TestCluster;

# fn main() -> pg_embedded_setup_unpriv::BootstrapResult<()> {
let cluster = TestCluster::new()?;
let conn = cluster.connection();

// Create a new database
conn.create_database("my_test_db")?;

// Check if a database exists
assert!(conn.database_exists("my_test_db")?);
assert!(conn.database_exists("postgres")?); // Built-in database

// Drop the database when done
conn.drop_database("my_test_db")?;
assert!(!conn.database_exists("my_test_db")?);
# Ok(())
# }

The TestCluster type also exposes convenience wrappers that delegate to the connection methods:

use pg_embedded_setup_unpriv::TestCluster;

# fn main() -> pg_embedded_setup_unpriv::BootstrapResult<()> {
let cluster = TestCluster::new()?;

// These delegate to cluster.connection().create_database(...) etc.
cluster.create_database("my_test_db")?;
assert!(cluster.database_exists("my_test_db")?);
cluster.drop_database("my_test_db")?;
# Ok(())
# }

All methods connect to the postgres database as the superuser to execute the Data Definition Language (DDL) statements. Errors are returned when:

  • Creating a database that already exists
  • Dropping a database that does not exist
  • Dropping a database with active connections
  • Connection to the cluster fails

Template databases for fast test isolation

PostgreSQL's CREATE DATABASE … TEMPLATE mechanism clones an existing database via a filesystem-level copy, completing in milliseconds regardless of schema complexity. This is significantly faster than running migrations on each test database.

use pg_embedded_setup_unpriv::TestCluster;

# fn main() -> pg_embedded_setup_unpriv::BootstrapResult<()> {
let cluster = TestCluster::new()?;

// Create a template database and apply migrations
cluster.create_database("my_template")?;
// ... run migrations on my_template ...

// Clone the template for each test (milliseconds vs seconds)
cluster.create_database_from_template("test_db_1", "my_template")?;
cluster.create_database_from_template("test_db_2", "my_template")?;
# Ok(())
# }

Template helpers live on TestClusterConnection and are also exposed on TestCluster for convenience. Use unique database names (for example, format!("test_{}", uuid::Uuid::new_v4())) to avoid collisions under parallel execution.

The ensure_template_exists method provides concurrency-safe template creation with per-template locking to prevent race conditions when multiple tests try to initialize the same template simultaneously. If the setup callback fails or panics after creating the template, the helper drops that partially created template before returning the error or resuming the panic:

use pg_embedded_setup_unpriv::TestCluster;

# fn main() -> pg_embedded_setup_unpriv::BootstrapResult<()> {
let cluster = TestCluster::new()?;

// Only creates and migrates if the template doesn't exist
cluster.ensure_template_exists("migrated_template", |db_name| {
    // Run migrations on the newly created database
    // e.g., diesel::migration::run(&mut conn)?;
    Ok(())
})?;

// Clone for the test
cluster.create_database_from_template("test_db", "migrated_template")?;
# Ok(())
# }

For versioned template names that automatically invalidate when migrations change, use the hash_directory helper to generate a content-based hash:

use pg_embedded_setup_unpriv::test_support::hash_directory;

# fn main() -> pg_embedded_setup_unpriv::BootstrapResult<()> {
let hash = hash_directory("migrations")?;
let template_name = format!("template_{}", &hash[..8]);
// Template name changes when any migration file changes
# Ok(())
# }

If you already track a migration version, include it in the template name instead (for example, format!("template_v{SCHEMA_VERSION}")). This keeps template invalidation explicit without hashing the migration directory.

Performance comparison

The following table reports approximate benchmark results from one development test environment. Treat the timings as environment-dependent guidance rather than guaranteed performance figures.

Table 3: Performance comparison for test isolation approaches.

Approach Bootstrap Per-test overhead Isolation
Per-test TestCluster Per test 20–30 seconds Full
Shared cluster, fresh database Once 1–5 seconds Database
Shared cluster, template clone Once 10–50 ms Database

When to use each approach:

  • Per-test cluster (test_cluster fixture): Use when tests modify cluster-level settings, require specific PostgreSQL versions, or need complete isolation from other tests.
  • Shared cluster with fresh databases: Use when tests need database-level isolation but can share the same cluster. Suitable when migration overhead is acceptable.
  • Shared cluster with template cloning (shared_test_cluster fixture): Use for maximum performance when tests only need database-level isolation. Requires upfront template creation, but reduces per-test overhead by orders of magnitude.

Database cleanup strategies

When using a shared cluster, databases created during tests persist until explicitly dropped or the cluster shuts down. Consider these strategies:

Explicit cleanup: Drop databases after each test to reclaim disk space and prevent name collisions:

use pg_embedded_setup_unpriv::TestCluster;

# fn main() -> pg_embedded_setup_unpriv::BootstrapResult<()> {
let cluster = TestCluster::new()?;
let db_name = format!("test_{}", uuid::Uuid::new_v4());
cluster.create_database_from_template(&db_name, "my_template")?;

// ... run test ...

cluster.drop_database(&db_name)?; // Explicit cleanup
# Ok(())
# }

Cluster teardown cleanup: Let the shared cluster drop all databases when the test binary exits. This is simpler but uses more disk space during the test run:

use pg_embedded_setup_unpriv::test_support::shared_cluster;

# fn main() -> pg_embedded_setup_unpriv::BootstrapResult<()> {
let cluster = shared_cluster()?;
let db_name = format!("test_{}", uuid::Uuid::new_v4());
cluster.create_database_from_template(&db_name, "my_template")?;

// ... run test ...
// Database dropped automatically when cluster shuts down
# Ok(())
# }

Active connection handling: Dropping a database with active connections fails. Ensure all connections are closed before calling drop_database. If using connection pools, drain the pool first.

Automatic cleanup with TemporaryDatabase

The TemporaryDatabase guard provides RAII cleanup semantics. When the guard goes out of scope, the database is automatically dropped:

use pg_embedded_setup_unpriv::TestCluster;

# fn main() -> pg_embedded_setup_unpriv::BootstrapResult<()> {
let cluster = TestCluster::new()?;

// Create a temporary database with automatic cleanup
let temp_db = cluster.temporary_database("my_test_db")?;

// Use the database
let url = temp_db.url();
// ... run queries ...

// Database is dropped automatically when temp_db goes out of scope
drop(temp_db);
# Ok(())
# }

For template-based workflows, use temporary_database_from_template:

use pg_embedded_setup_unpriv::TestCluster;

# fn main() -> pg_embedded_setup_unpriv::BootstrapResult<()> {
let cluster = TestCluster::new()?;

// Ensure the template exists
cluster.ensure_template_exists("migrated_template", |_| Ok(()))?;

// Create a temporary database from the template
let temp_db = cluster.temporary_database_from_template("test_db", "migrated_template")?;

// Database is automatically dropped when temp_db goes out of scope
# Ok(())
# }

Drop behaviour:

  • drop_database() — Explicitly drop the database, failing if connections exist. Consumes the guard.
  • force_drop() — Terminate active connections before dropping. Useful when connection pools haven't been drained.
  • Implicit drop (guard goes out of scope) — Best-effort drop with a warning logged on failure.