Postgres is a relational database known for its scalability, reliability, and ease of use. It's one of the most popular databases in the world and one of the reasons for its popularity is the ability to extend the database with custom functions, data types and more using the Postgres Extensions API.
In this post, we'll learn to build a Postgres extension from scratch touching upon all the core concepts. Some knowledge of C, Docker and Postgres is preferred but not required.
At its core, a Postgres extension is a collection of 4 files:
- The
.c
source code file that contains the main business logic for the extension. - A
.sql
file that contains the SQL code for the extension. - A
.control
file that serves as a manifest and defines the extension name, version, and dependencies. - A
Makefile
that defines how to build the extension.
The C source file
This is our main source code for the extension. We'll organize our source files in a src/
directory to keep things clean. Let's build up our simple "hello world" function by understanding each component first.
Every postgres extension starts with including the necessary header files. We need three essential headers:
postgres.h
- contains core data types and macros that every extension needs.fmgr.h
- handles the Function Manager interface, defining macros likePG_FUNCTION_ARGS
andPG_RETURN_*
for function communication.utils/builtins.h
- provides utility functions includingcstring_to_text()
for converting C strings to Postgres's text type.
#include "postgres.h"
#include "fmgr.h"
#include "utils/builtins.h"
Next, we need to include a mandatory macro that serves as a version check containing information about the Postgres version, architecture, and ABI compatibility details. Without it, Postgres will refuse to load your extension.
PG_MODULE_MAGIC;
Before defining our function, we need to register it with Postgres using a macro that declares metadata like the function name and calling convention. Think of it as registering your function with Postgres's function registry.
PG_FUNCTION_INFO_V1(hello_world);
The function name
hello_world
here must exactly match the function name you'll define in your SQL file and the actual C function name below.
Finally we define our function. Our function returns a Datum
(Postgres's universal data type) and takes PG_FUNCTION_ARGS
as input, which contains all function arguments and metadata. Inside the function, cstring_to_text()
converts our C string to Postgres's internal text type, and PG_RETURN_TEXT_P()
properly returns the text value.
Datum hello_world(PG_FUNCTION_ARGS)
{
PG_RETURN_TEXT_P(cstring_to_text("Hello from hello world extension!"));
}
Putting it all together, here's our completed src/hello_world.c
file
#include "postgres.h"
#include "fmgr.h"
#include "utils/builtins.h"
PG_MODULE_MAGIC;
PG_FUNCTION_INFO_V1(hello_world);
Datum hello_world(PG_FUNCTION_ARGS)
{
PG_RETURN_TEXT_P(cstring_to_text("Hello from hello world extension!"));
}
When someone calls SELECT hello_world();
, Postgres looks up the function metadata, calls our C function with the standard interface, creates a Postgres text object from our string, and returns it to the user. This demonstrates the fundamental pattern all Postgres extensions follow: receive arguments through the standard interface, process them using Postgres's data types, and return results using Postgres's macros.
The SQL File
The SQL file defines the SQL interface for your extension. It creates the function signatures that map to your C functions. We'll place all SQL files in a sql/
directory to keep them organized. Let's build up our SQL file step by step.
First, we add a safety mechanism to prevent the SQL file from being executed directly
-- complain if script is sourced in psql, rather than via CREATE EXTENSION
\echo Use "CREATE EXTENSION hello_world" to load this file. \quit
This prevents the SQL file from being executed directly with psql
. It should only be run through CREATE EXTENSION
.
Next, we define the basic function signature:
CREATE OR REPLACE FUNCTION hello_world()
RETURNS text
CREATE OR REPLACE FUNCTION
defines the SQL function signature, and RETURNS text
specifies that the function returns a text value.
Now we link our SQL function to the C function:
AS 'MODULE_PATHNAME', 'hello_world'
LANGUAGE C
AS 'MODULE_PATHNAME', 'hello_world'
links the SQL function to the C function. MODULE_PATHNAME
is replaced by the path from the control file. LANGUAGE C
indicates this is a C function.
Note: The second
'hello_world'
parameter must exactly match your C function name and the name used inPG_FUNCTION_INFO_V1()
. TheMODULE_PATHNAME
is automatically replaced with themodule_pathname
value from your control file.
Finally, we add performance optimization hints:
IMMUTABLE STRICT;
IMMUTABLE
means the function always returns the same result for the same input, and STRICT
means it returns NULL if any argument is NULL.
Here's our complete sql/hello_world--0.0.1.sql
file:
Note: The SQL file name follows the pattern
{extension_name}--{version}.sql
. Thehello_world
part must match theEXTENSION
name in your Makefile and the control file name. The0.0.1
version must match thedefault_version
in your control file.
-- complain if script is sourced in psql, rather than via CREATE EXTENSION
\echo Use "CREATE EXTENSION hello_world" to load this file. \quit
CREATE OR REPLACE FUNCTION hello_world()
RETURNS text
AS 'MODULE_PATHNAME', 'hello_world'
LANGUAGE C IMMUTABLE STRICT;
The Control File
The control file serves as a manifest for your extension. It contains metadata that Postgres uses to understand how to install, manage, and interact with your extension. Let's build up our control file step by step.
First, we need to provide a description and version for our extension:
# hello_world extension
comment = 'Simple hello world extension for Postgres'
default_version = '0.0.1'
The comment
provides a human-readable description of what your extension does, while default_version
specifies the version to install when no version is specified.
Next, we tell Postgres where to find our compiled shared library:
module_pathname = '$libdir/hello_world'
module_pathname
points to the compiled shared library. $libdir
is a Postgres variable that points to the library directory.
Finally, we configure how the extension behaves in different schemas:
relocatable = true
relocatable
determines whether the extension can be moved to a different schema after installation.
Here's our complete hello_world.control
file:
Note: The control file name must be
{extension_name}.control
. Thehello_world
part must exactly match theEXTENSION
variable in your Makefile.
# hello_world extension
comment = 'Simple hello world extension for PostgreSQL'
default_version = '0.0.1'
module_pathname = '$libdir/hello_world'
relocatable = true
Note: The
default_version = '0.0.1'
must match the version in your SQL file name (hello_world--0.0.1.sql
). Themodule_pathname = '$libdir/hello_world'
should use the same name as yourMODULE_big
variable in the Makefile.
The Makefile
The Makefile defines how to build the extension. Let's build it up step by step to understand each component.
First, we define the basic identity of our extension:
EXTENSION = hello_world
MODULE_big = hello_world
EXTENSION
is the name of the extension and MUST match the .control file name. MODULE_big
is the name of the shared library that will be created.
Note:
EXTENSION = hello_world
must exactly match your control file name (hello_world.control
) and the prefix of your SQL file (hello_world--0.0.1.sql
).MODULE_big = hello_world
should match the library name in your control file'smodule_pathname
.
Next, we specify which files should be included in the build:
DATA = sql/hello_world--0.0.1.sql
OBJS = src/hello_world.o
DATA
lists the SQL files that should be installed with the extension. OBJS
specifies the object files that need to be compiled and linked.
Note:
DATA = sql/hello_world--0.0.1.sql
must point to your actual SQL file location and name.OBJS = src/hello_world.o
will be compiled fromsrc/hello_world.c
- the.c
extension becomes.o
during compilation.
Now we set up the build configuration:
PG_CONFIG = pg_config
PG_CONFIG
is the path to the pg_config command, which provides build configuration information.
Finally, we include the Postgres build system:
PGXS := $(shell $(PG_CONFIG) --pgxs)
include $(PGXS)
PGXS
is the Postgres Extension Building Infrastructure that provides all the build rules and targets we need.
Here's our complete Makefile:
EXTENSION = hello_world
MODULE_big = hello_world
DATA = sql/hello_world--0.0.1.sql
OBJS = src/hello_world.o
PG_CONFIG = pg_config
PGXS := $(shell $(PG_CONFIG) --pgxs)
include $(PGXS)
Local Development with Docker
Instead of installing Postgres development packages locally, we'll use Docker to ensure a consistent build environment. We'll use the official Postgres Docker image and build our extension in a container.
ARG PG_MAJOR=17
FROM postgres:$PG_MAJOR
ARG PG_MAJOR
COPY . /tmp/pg_hello_world
WORKDIR /tmp/pg_hello_world
RUN apt-get update && \
apt-get install -y --no-install-recommends \
build-essential \
postgresql-server-dev-$PG_MAJOR && \
make clean && \
make OPTFLAGS="" && \
make install && \
chmod -R 777 /tmp/pg_hello_world
This Dockerfile starts with the official PostgreSQL image, installs the necessary build tools and development headers, then compiles and installs our extension using the PGXS build system. We keep the build tools and source code available for testing and development.
Now that we have all our files, here's what our project structure looks like
hello_world/
├── src/
│ └── hello_world.c
├── sql/
│ └── hello_world--0.0.1.sql
├── hello_world.control
├── Makefile
└── Dockerfile
To test our extension locally, we'll use Docker Compose. Create a docker-compose.yml
file in your project root:
services:
postgres:
build: .
restart: always
shm_size: 128mb
environment:
POSTGRES_USER: root
POSTGRES_PASSWORD: password
volumes:
- ./:/tmp/pg_hello_world
This configuration builds our custom Postgres image with the hello_world extension and sets up the database with a password. Run the following command to start the container
# Build and start the container
docker compose up -d --build
# Check if the container is running
docker compose ps
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
pghashlib-postgres-1 pghashlib-postgres "docker-entrypoint.s…" postgres 14 seconds ago Up 14 seconds 5432/tcp
Now we can connect to the database and test our extension.
# Connect to the database
docker compose exec postgres psql
# Create the extension
CREATE EXTENSION hello_world;
# Test the function
SELECT hello_world();
You should see the following output
hello_world
-----------------------------------
Hello from hello world extension!
(1 row)
When you're done testing, you can stop and remove the containers
# Stop the containers
docker compose down
Writing Tests
Testing is crucial for extension development since bugs can crash the entire PostgreSQL server. PostgreSQL provides pg_regress
, a robust testing framework used by PostgreSQL itself and the standard way to test extensions.
For our hello_world extension, we'll create test files in a dedicated tests/
directory:
hello_world/
├── src/
│ └── hello_world.c
├── sql/
│ └── hello_world--0.0.1.sql
├── tests/
│ ├── sql/
│ │ └── hello_world.sql # Test SQL commands
│ └── expected/
│ └── hello_world.out # Expected output
├── hello_world.control
├── Makefile
└── Dockerfile
Create the test SQL file tests/sql/hello_world.sql
:
-- Test basic functionality
SELECT hello_world();
-- Test function properties
SELECT
proname,
provolatile,
proisstrict
FROM pg_proc
WHERE proname = 'hello_world';
-- Test extension metadata
SELECT
extname,
extversion
FROM pg_extension
WHERE extname = 'hello_world';
Generate the expected output by running the test manually and saving the output to tests/expected/hello_world.out
:
-- Test basic functionality
SELECT hello_world();
hello_world
-----------------------------------
Hello from hello world extension!
(1 row)
-- Test function properties
SELECT
proname,
provolatile,
proisstrict
FROM pg_proc
WHERE proname = 'hello_world';
proname | provolatile | proisstrict
-------------+-------------+-------------
hello_world | i | t
(1 row)
-- Test extension metadata
SELECT
extname,
extversion
FROM pg_extension
WHERE extname = 'hello_world';
extname | extversion
-------------+------------
hello_world | 0.0.1
(1 row)
The updated Makefile includes several improvements over the basic configuration. The new test configuration automatically discovers test files, loads the extension automatically, and provides better error reporting:
Update your Makefile to include the test configuration:
EXTENSION = hello_world
MODULE_big = hello_world
DATA = sql/hello_world--0.0.1.sql
OBJS = src/hello_world.o
PG_CONFIG = pg_config
# Test configuration
TESTS = $(wildcard tests/sql/*.sql)
REGRESS = $(patsubst tests/sql/%.sql,%,$(TESTS))
REGRESS_OPTS = --inputdir=tests --load-extension=$(EXTENSION)
PGXS := $(shell $(PG_CONFIG) --pgxs)
include $(PGXS)
# Custom test target that shows diffs on failure
test:
@$(MAKE) --silent installcheck || (echo "=== DIFF ===" && cat regression.diffs 2>/dev/null && exit 1)
.PHONY: test
Let's break down what each new line does:
TESTS = $(wildcard tests/sql/*.sql)
- Automatically discovers all.sql
files in thetests/sql/
directory, making it easy to add new tests without modifying the MakefileREGRESS = $(patsubst tests/sql/%.sql,%,$(TESTS))
- Converts the full file paths to test names by removing the directory prefix and.sql
extension--load-extension=$(EXTENSION)
- Automatically loads your extension before running tests, eliminating the need for manualCREATE EXTENSION
commands in test files- The custom
test
target makes it easier to run tests and see the diffs on failure.
Running Tests with Docker
You can run the pg_regress tests in your Docker environment by building and installing the extension first:
# Build and start the extension
docker compose up -d --build
# Run the regression tests
docker compose exec postgres make installcheck
The make installcheck
command will run your test SQL files against the installed extension and compare the output with your expected results. If everything is working, you should see the following output:
echo "# +++ regress install-check in +++" && /usr/lib/postgresql/17/lib/pgxs/src/makefiles/../../src/test/regress/pg_regress --inputdir=./ --bindir='/usr/lib/postgresql/17/bin' --inputdir=tests --load-extension=hello_world --dbname=contrib_regression hello_world
# +++ regress install-check in +++
# using postmaster on Unix socket, default port
ok 1 - hello_world 7 ms
1..1
# All 1 tests passed.
What's Next?
Creating Postgres extensions opens up endless possibilities for extending your database's capabilities. From simple utility functions to complex data types and operators, extensions allow you to implement performance-critical functionality directly in the database server.
The pattern we've explored - C functions wrapped in SQL interfaces, managed through control files and built with PGXS - forms the foundation for all Postgres extensions. Whether you're building a simple function or a complex full-text search engine, these same principles apply.
In a future post, we'll explore how to build more complex extensions for real world use cases and also explore pgrx, a tool for building Postgres extensions in Rust.