PostgreSQL 18's extension_control_path: Decoupling Extensions from Server Images
PostgreSQL 18 adds a Grand Unified Configuration (GUC), extension_control_path, that lets an extension's control and SQL files live outside the server's own directories. Together with Kubernetes's ImageVolume and Docker's --mount type=image, that makes it practical to package an extension as its own small OCI container image and mount it into the PostgreSQL pod at runtime, without rebuilding the server image.
The idea is compelling. A fleet of clusters sharing one lean, unmodified PostgreSQL image. Extensions versioned and upgraded independently. pgvector 0.8 today, 0.9 tomorrow, without touching the server layer - sounds clean.
It is clean - for the right extensions. For others, the container boundary doesn't actually decouple anything meaningful. Whether the extension deserves its own container depends almost entirely on how deeply it is wired into the server's startup sequence, process tree, and storage subsystem. This post maps out that landscape.
What PostgreSQL 18 Actually Changed
Before PostgreSQL 18, dynamic_library_path already let you place shared libraries (.so files) outside the compiled-in $libdir. While dynamic_library_path took care of the binary side of an extension, the gap was the control side: extension control files (.control) and SQL scripts had to live in $sharedir/extension/ with no override mechanism. Any extension that wasn't installed in the server's own share directory simply could not be found by CREATE EXTENSION.
PostgreSQL 18 closes that gap by adding extension_control_path - a GUC that works exactly like dynamic_library_path but for control and SQL files. Set both GUCs together, and an extension can live entirely outside the PostgreSQL installation:
extension_control_path = '/ext/share:$system'
dynamic_library_path = '/ext/lib:$libdir'One subtlety that tripped us up during testing: extension_control_path takes sharedir-level paths, not the extension subdirectory directly. PostgreSQL appends /extension/ automatically when scanning for .control files. Specifying the full path to the extension subdirectory silently finds nothing, not even built-in extensions like plpgsql.
The $system placeholder resolves to the compiled in sharedir (the path returned by pg_config --sharedir). It must appear in extension_control_path or the server loses access to all its own built-in extensions. The same logic applies to $libdir in dynamic_library_path.
The Extension Container Layout
The extension image stores files at three portable paths, independent of where PostgreSQL happens to be installed in the server image:
/lib/ ← shared libraries: extension.so, output_plugin.so, bitcode/
/share/ ← control and SQL files: extension.control, extension--1.0.sql
/bin/ ← extension binaries: spockctrl, pg_receivelogical, etc.Runtime dependencies that aren't present in the server image, libjansson for spock, libgeos for PostGIS, go into /lib/ as well. The server process finds them via LD_LIBRARY_PATH=/ext/lib, which CloudNativePG injects automatically from the ld_library_path field in the extension's metadata.hcl descriptor.
A single multi-stage Dockerfile builds images of every PostgreSQL major version by varying the PG_MAJOR build argument. A .so file compiled for PG 17 will not load in PG 18, the internal ABI changes between major versions - so each major version gets its own image, tagged accordingly: spock:5.0.8-18-el9, spock:5.0.8-17-el9.
Testing in Docker
Docker Engine 28 shipped --mount type=image as a stable feature (it was experimental in earlier releases). It mounts an OCI image's filesystem directly into a running container, the equivalent of Kubernetes ImageVolume without needing a cluster:
docker run -d \
--mount type=image,source=spock:5.0.8-18-el9,target=/ext,readonly \
-e LD_LIBRARY_PATH=/ext/lib \
postgres-18-no-spock:latest \
postgres \
-c shared_preload_libraries=spock \
-c dynamic_library_path=/ext/lib:'$libdir' \
-c extension_control_path=/ext/share:'$system' \
-c wal_level=logical \
-c track_commit_timestamp=onThe server image in this test contained no spock files whatsoever. After startup, CREATE EXTENSION spock succeeds and \dx confirmed spock 5.0.8 installed.
What About PG 16 and PG 17?
extension_control_path is new for PG 18; on PG 16 and PG 17, dynamic_library_path can still place .so files outside $libdir, but .control files must reside in $sharedir/extension/. In Kubernetes, an init container can copy them from the extension image into an emptyDir volume mounted at the server's extension directory via subPath. This works but is fragile: the path must match exactly, the init container must run on every pod start, and the approach is invisible to operators like CloudNativePG that treat the extension directory as server owned. The clean story is PG 18.
Which Extensions Actually Benefit?
The container pattern delivers files to a mount point. That is all it does. Whether that changes anything meaningful about how you operate an extension depends on what the extension needs from PostgreSQL beyond its files, and those needs vary enormously.
Category 1: Pure On-Demand Extensions
Best fit. The full promise of the pattern applies here.
Examples: pgvector, PostGIS, and pg_partman (SQL-only mode)
These extensions are invisible to PostgreSQL until a client runs CREATE EXTENSION. There are no entries in shared_preload_libraries, no background processes, no server-level GUC changes; the postmaster has no knowledge of them at startup. The .so file is loaded on demand into the backend process that first uses the extension, and unloaded when that process exits.
This architecture means the extension container can be attached or detached from a running cluster without a server restart. Mount the image, update the two GUCs in postgresql.conf (or pass them at startup), and CREATE EXTENSION works on the next connection. Drop the extension, unmount the image, and the server continues normally. CloudNativePG handles this lifecycle automatically when the extension image is added to or removed from the cluster spec.
The operational savings compound over time. pgvector's extension container image is 613 KB. PostGIS (with its GEOS and PROJ dependencies) fits in a few megabytes. These images are scanned, patched, and versioned completely independently of the server image. When pgvector releases a patch for a performance regression, you update one image tag across your fleet without rebuilding anything else.
This independence is what lets you run several extensions side by side, each from its own image. extension_control_path is not a single directory that PostgreSQL scans for whatever happens to be there; it is an ordered, colon-separated search path, exactly like dynamic_library_path or the shell's PATH. For CREATE EXTENSION foo, PostgreSQL walks each listed directory in turn, appends /extension/, and looks for foo.control, stopping at the first match. It never auto-discovers extensions by enumerating subdirectories. So each extension image you mount contributes its own sharedir and libdir entry to the two paths:
extension_control_path='/ext/pgvector/share:/ext/postgis/share:/ext/spock/share:$system'
dynamic_library_path='/ext/pgvector/lib:/ext/postgis/lib:/ext/spock/lib:$libdir'Mount pgvector, PostGIS, and Spock as three separate image volumes, point each pair of entries at the matching target, and keep $system and $libdir last so the server's built-in extensions still resolve. Each image is then scanned, patched, and version-bumped on its own tag, updating pgvector touches neither PostGIS nor the server image.
Category 2: Hook-Based Extensions
Good fit. Independent versioning without hot attach.
Examples: pgaudit, pg_stat_statements, auto_explain, pg_hint_plan, pg_qualstats
Hook based extensions intercept PostgreSQL's internal execution pipeline. pgaudit hooks the executor to log every statement that matches an audit policy. pg_stat_statements hooks the planner and executor to track query statistics across all sessions. Because these hooks must be in place before any query runs, the extension must be loaded at server startup via shared_preload_libraries.
This is the caveat: adding or removing a hook based extension always requires a server restart to update shared_preload_libraries. The hot attach capability that makes on-demand extensions so attractive simply does not exist here. The GUC change requires a full restart, which in CloudNativePG means a rolling restart of the cluster.
What the container model does preserve is independent versioning. pgaudit ships only a .so and SQL files, no background workers, no shared memory segments, no WAL-level requirements. When a pgaudit patch ships, swap the image tag and restart. The server image is untouched. For a team managing dozens of clusters where pgaudit is an optional compliance requirement on some but not all, keeping it out of the base image while still versioning it independently is a genuine operational improvement.
Category 3: Background Worker Extensions
Situational. Worth it only if independent versioning has clear value.
Examples: pg_cron, pg_partman (with background worker), pg_repack
Background worker extensions register persistent OS level processes with the postmaster. pg_cron's worker wakes up every minute to dispatch scheduled jobs. pg_partman's background worker handles automatic partition maintenance. These processes are part of the server's process tree, they start when the postmaster starts and are killed when it shuts down. They require shared_preload_libraries for the same reason hook-based extensions do: the postmaster must know about them before it forks any workers.
The container pattern handles file delivery cleanly, but attaching or detaching the extension means a server restart regardless, and the extension's process is coupled to the server's lifecycle in a way that pure on demand extensions are not. If pg_cron's worker crashes, the postmaster will log it and potentially restart the entire server. The operational surface is meaningfully larger than a pure on demand extension.
This category is worth the container approach if you have a specific operational requirement: many clusters where pg_cron is optional, or an environment where your compliance process requires separate vulnerability scanning for every binary artifact. Otherwise, the added complexity of managing an extra image for marginal decoupling benefit may not be worth it.
Category 4: System Integrated Extensions
Evaluate carefully. File delivery is not the binding constraint.
Examples: spock, pglogical, TimescaleDB, Citus
System integrated extensions don't just extend PostgreSQL - they modify how it operates at a fundamental level. File delivery is the least of their coupling concerns. Consider what spock actually requires from the server:
Shared memory allocation at postmaster startup. Spock registers shared memory segments via shmem_request_hook. PostgreSQL's shared memory layout is determined before any user connections are accepted and cannot be changed without a restart. If spock is not in shared_preload_libraries when the postmaster starts, the memory segment is never created, and there is no way to create it afterward.
WAL level. Spock requires wal_level = logical, which instructs PostgreSQL to write enough information to the WAL for logical decoding. This is a server wide setting: every transaction on the server, in every database, generates a larger WAL record than it would at the default replica level. It requires a restart to change.
track_commit_timestamp. Spock requires this for its conflict resolution strategies (keep_local, last_update_wins, first_update_wins). Enabling it causes PostgreSQL to record a timestamp for every committed transaction in pg_commit_ts, additional storage consumed on every write across all databases. Changing it requires a restart.
Multiple background workers with a live state. Spock runs a supervisor process that manages apply workers, manager workers, and writer workers. These hold active TCP connections to remote PostgreSQL nodes. Stopping the server terminates these connections, which the remote side must detect and handle.
Replication slots. Logical replication slots prevent PostgreSQL's vacuum from reclaiming space for rows that have been deleted or updated until every subscriber has acknowledged receiving those changes. This is a server level storage concern: a lagging or disconnected subscriber can cause unbounded WAL accumulation on the primary.
For an extension like this, the question "can I attach it to a running cluster by mounting a container image?" has the same answer whether you use extension_control_path or not: no. The binding constraints are the server configuration parameters, the shared memory layout, and the replication topology, none of which are affected by where the extension files live.
That said, the container model still offers real value for system-integrated extensions: upgrading spock from 5.0.7 to 5.0.8 without rebuilding the server image, keeping the server image lean and auditable, and separating the spock release cycle from the PostgreSQL release cycle. These are genuine operational improvements. Just don't expect hot-attach from some of your favorite extensions, that's not a file delivery problem.
Quick Reference
The table below summarizes how each extension category maps to the container delivery model.
Version IndependenceisFullif you can upgrade the extension without rebuilding the server image.Hot-AttachisYesif you can add the extension to a running cluster without a server restart.
| Category | Examples | Restart Required? | Hot-Attach? | Version Independence | Verdict |
|---|---|---|---|---|---|
| Pure on-demand | pgvector, PostGIS | No | Yes | Full | Ideal |
| Hook-based | pgaudit, pg_stat_statements, auto_explain | Yes (shared_preload_libraries) | No | Full | Good fit |
| Background workers | pg_cron, pg_partman (BGW) | Yes | No | Full | Situational |
| System-integrated | spock, pglogical, TimescaleDB, Citus | Yes + coordination | No | Versioning only | Evaluate carefully |
The Right Tool for the Right Extension
extension_control_path finishes what dynamic_library_path starts. Together they make it possible to serve an entire PostgreSQL extension, control files, SQL scripts, shared libraries, and runtime dependencies, from outside the server installation. That is a meaningful capability, and the Kubernetes ImageVolume integration makes it production ready for PG 18 clusters on Kubernetes 1.33 and later (ImageVolume reached beta in 1.33 and is GA as of 1.36).
The extensions that get the most from this model are the ones with the loosest coupling to the server: pgvector, PostGIS, and similar on-demand extensions that load only when called and leave no footprint on the server when they're not in use. For these, the extension container model delivers exactly what it promises - hot-attach, independent versioning, and a server image that never needs to be rebuilt just because an extension released a patch.
For hook-based and background worker extensions, the model is still useful, but the benefit is narrower: independent versioning, not hot-attach. Whether that narrower benefit justifies the added operational surface depends on your fleet and your release process.
For deeply system integrated extensions, replication systems, distributed query engines, extensions that reshape how PostgreSQL handles WAL or shared memory, the container boundary does not decouple the parts that matter. The extension files can live anywhere; the coupling lives in the server configuration, the process tree, and the replication topology. Those constraints exist regardless of how the files are delivered.
Know what your extension actually does to the server, and the right answer becomes obvious.

