Building PostgreSQL Extensions with Rust: A Complete Guide Using pgrx
A practical guide to pgrx with a real-world data masking example

I am software developer, primarily working on the nodejs, graphql, react and mongoDB.
Building PostgreSQL Extensions with Rust: A Complete Guide Using pgrx
PostgreSQL is one of the most extensible databases ever built. Its extension system lets you add custom data types, functions, operators, and even entirely new storage engines. Traditionally, this power came with a cost: you had to write C code.
C is fast, but it's also unforgiving. A single buffer overflow or null pointer dereference can crash your entire database server—taking all your connections and transactions down with it. For a production database, that's terrifying.
Enter Rust.
In this guide, I'll show you how to build PostgreSQL extensions using Rust and the pgrx framework. We'll use a real extension—pg_mask—as our working example: a set of data masking functions for GDPR and privacy compliance.
Why Rust Over C?
If you've written C for PostgreSQL before, you know the pain:
Memory management is manual. Forget to
pfree()something? Memory leak. Free it twice? Crash.Error handling is tedious. PostgreSQL's
ereport()system works, but it's easy to get wrong.Tooling is antiquated. No package manager, no standardized testing, dependency management via copy-paste.
Rust solves all of this:
| Problem | C | Rust |
| Memory safety | Manual, error-prone | Compiler-enforced |
| Null pointer bugs | Runtime crashes | Compile-time prevention |
| Package management | Copy-paste code | Cargo + crates.io |
| Testing | Roll your own | Built-in test framework |
| Error handling | Macros + longjmp | Result types + ? operator |
The key insight: Rust gives you C's performance without C's footguns. Your PostgreSQL server won't crash because of a segfault in your extension code.
The pgrx Framework
pgrx is the bridge between Rust and PostgreSQL. It handles:
FFI bindings: All the unsafe C interop is wrapped in safe Rust APIs
Memory contexts: Automatically integrates with PostgreSQL's memory management
Error handling: Rust panics become PostgreSQL errors (no crashes!)
SQL generation: Your Rust function signatures automatically become SQL function definitions
Development server: Spin up a PostgreSQL instance with your extension pre-loaded
The magic is that pgrx lets you write idiomatic Rust while producing extensions that are indistinguishable from C extensions to PostgreSQL.
Project Structure: Anatomy of a pgrx Extension
Let's look at pg_mask, a data masking extension. Here's the complete project structure:
pg_mask/
├── .cargo/
│ └── config.toml # Platform-specific linker settings
├── src/
│ ├── lib.rs # Your extension code lives here
│ └── bin/
│ └── pgrx_embed.rs # Required for SQL generation
├── Cargo.toml # Rust project configuration
├── pg_mask.control # PostgreSQL extension metadata
└── SETUP.md # Documentation
That's it. Five files to create a production-ready PostgreSQL extension.
Let's examine each file.
Cargo.toml: Project Configuration
[package]
name = "pg_mask"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib", "lib"]
[[bin]]
name = "pgrx_embed_pg_mask"
path = "src/bin/pgrx_embed.rs"
[features]
default = ["pg18"]
pg18 = ["pgrx/pg18", "pgrx-tests/pg18"]
pg_test = []
[dependencies]
pgrx = "0.16"
[dev-dependencies]
pgrx-tests = "0.16"
Key points:
crate-type = ["cdylib", "lib"]: Builds both a C-compatible shared library (for PostgreSQL to load) and a Rust library (for testing)Feature flags for PostgreSQL versions: pgrx supports multiple PostgreSQL versions. Pick yours with a feature flag.
Single dependency: Just
pgrx. That's all you need.
pg_mask.control: Extension Metadata
Every PostgreSQL extension needs a .control file:
comment = 'pg_mask: Data masking functions for GDPR/privacy compliance'
default_version = '@CARGO_VERSION@'
module_pathname = '$libdir/pg_mask'
relocatable = false
superuser = false
comment: Shows up in\dxlistingsdefault_version: pgrx replaces@CARGO_VERSION@with your Cargo.toml versionmodule_pathname: Where PostgreSQL looks for your shared librarysuperuser = false: Regular users can use these functions (appropriate for data masking)
Important: The control file name must match your extension name (not necessarily the crate name). Extension name pg_mask → pg_mask.control.
The Embedding Binary
// src/bin/pgrx_embed.rs
::pgrx::pgrx_embed!();
This one-liner is required by pgrx for SQL generation. It generates the PostgreSQL-compatible module initialization code and the SQL schema from your Rust function definitions. Without it, cargo pgrx commands will fail.
macOS Linker Configuration
If you're developing on macOS, you need this in .cargo/config.toml:
[target.aarch64-apple-darwin]
rustflags = ["-C", "link-arg=-undefined", "-C", "link-arg=dynamic_lookup"]
[target.x86_64-apple-darwin]
rustflags = ["-C", "link-arg=-undefined", "-C", "link-arg=dynamic_lookup"]
Why? PostgreSQL extensions are shared libraries that reference symbols from the PostgreSQL server binary. On macOS, the linker normally requires all symbols to be resolved at link time. The -undefined dynamic_lookup flags tell the linker "trust me, these symbols will exist at runtime."
Without this, your build will fail with undefined symbol errors for PostgreSQL internal functions.
Writing Extension Functions
Now the fun part. Here's the complete src/lib.rs:
use pgrx::prelude::*;
pgrx::pg_module_magic!();
/// Masks an email address, showing only first char and domain
/// Example: "john.doe@example.com" -> "j*******@example.com"
#[pg_extern]
fn mask_email(email: &str) -> String {
match email.split_once('@') {
Some((local, domain)) => {
if local.is_empty() {
format!("@{}", domain)
} else {
let first_char = local.chars().next().unwrap();
let mask_len = local.len().saturating_sub(1);
format!("{}{}@{}", first_char, "*".repeat(mask_len), domain)
}
}
None => "*".repeat(email.len()),
}
}
/// Masks a credit card number, showing only last 4 digits
/// Example: "4111111111111111" -> "****-****-****-1111"
#[pg_extern]
fn mask_card(card: &str) -> String {
let digits: String = card.chars().filter(|c| c.is_ascii_digit()).collect();
if digits.len() >= 4 {
let last_four = &digits[digits.len() - 4..];
format!("****-****-****-{}", last_four)
} else {
"*".repeat(digits.len())
}
}
/// Masks a phone number, showing only last 4 digits
/// Example: "+1 (555) 123-4567" -> "***-***-4567"
#[pg_extern]
fn mask_phone(phone: &str) -> String {
let digits: String = phone.chars().filter(|c| c.is_ascii_digit()).collect();
if digits.len() >= 4 {
let last_four = &digits[digits.len() - 4..];
format!("***-***-{}", last_four)
} else {
"*".repeat(digits.len())
}
}
/// Masks a SSN, showing only last 4 digits
/// Example: "123-45-6789" -> "***-**-6789"
#[pg_extern]
fn mask_ssn(ssn: &str) -> String {
let digits: String = ssn.chars().filter(|c| c.is_ascii_digit()).collect();
if digits.len() >= 4 {
let last_four = &digits[digits.len() - 4..];
format!("***-**-{}", last_four)
} else {
"*".repeat(digits.len())
}
}
/// Generic masking: shows first N and last M characters
/// Example: mask_text("sensitive data", 2, 2) -> "se**********ta"
#[pg_extern]
fn mask_text(text: &str, show_first: i32, show_last: i32) -> String {
let show_first = show_first.max(0) as usize;
let show_last = show_last.max(0) as usize;
let len = text.chars().count(); // Use chars().count() for Unicode safety
if show_first + show_last >= len {
return text.to_string();
}
let first: String = text.chars().take(show_first).collect();
let last: String = text.chars().skip(len - show_last).collect();
let mask_len = len - show_first - show_last;
format!("{}{}{}", first, "*".repeat(mask_len), last)
}
/// Masks an IP address
/// IPv4: "192.168.1.100" -> "192.168.xxx.xxx"
/// IPv6: Shows first segment only
#[pg_extern]
fn mask_ip(ip: &str) -> String {
if ip.contains(':') {
// IPv6
let parts: Vec<&str> = ip.split(':').collect();
if parts.is_empty() {
return "xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx".to_string();
}
let first = parts[0];
let masked: Vec<&str> = std::iter::once(first)
.chain(std::iter::repeat("xxxx").take(parts.len() - 1))
.collect();
masked.join(":")
} else {
// IPv4
let parts: Vec<&str> = ip.split('.').collect();
if parts.len() >= 2 {
format!("{}.{}.xxx.xxx", parts[0], parts[1])
} else {
"xxx.xxx.xxx.xxx".to_string()
}
}
}
Let's break down the key concepts.
pg_module_magic!()
pgrx::pg_module_magic!();
This macro generates the PostgreSQL module magic block—a special struct that PostgreSQL checks to verify the extension was compiled for the right PostgreSQL version. In C, you'd write this manually. In Rust, it's one line.
#[pg_extern]: Exposing Functions to SQL
The #[pg_extern] attribute is where the magic happens:
#[pg_extern]
fn mask_email(email: &str) -> String {
// ...
}
This single annotation:
Makes the function callable from SQL
Generates the SQL
CREATE FUNCTIONstatementHandles type conversions between PostgreSQL and Rust
Wraps panics in proper PostgreSQL error handling
The function signature fn mask_email(email: &str) -> String automatically becomes:
CREATE FUNCTION mask_email(email TEXT) RETURNS TEXT
pgrx handles the mapping:
| Rust Type | PostgreSQL Type |
&str | TEXT |
String | TEXT |
i32 | INTEGER |
i64 | BIGINT |
f64 | DOUBLE PRECISION |
bool | BOOLEAN |
Option<T> | Nullable T |
Writing Safe Code
Notice how the code handles edge cases:
match email.split_once('@') {
Some((local, domain)) => {
// Valid email with @
}
None => "*".repeat(email.len()), // No @ found
}
In C, you'd be doing pointer arithmetic and praying you don't overflow. In Rust, the compiler ensures you handle both cases. If you forget the None branch, your code won't compile.
Building and Running
Initial Setup
# Install the pgrx CLI tool
cargo install cargo-pgrx --version 0.16.1 --locked
# Initialize pgrx (required before first build!)
cargo pgrx init --pg18=$(which pg_config)
The init step downloads PostgreSQL headers and sets up the build environment. You must run this before your first build.
Development Workflow
# Start a PostgreSQL instance with your extension loaded
cargo pgrx run pg18
This command:
Compiles your extension
Starts a temporary PostgreSQL server
Connects you to a
psqlsession
From there:
-- Load the extension
CREATE EXTENSION pg_mask;
-- Test your functions
SELECT mask_email('john.doe@example.com');
-- Returns: j*******@example.com
SELECT mask_card('4111111111111111');
-- Returns: ****-****-****-1111
SELECT mask_phone('+1 (555) 123-4567');
-- Returns: ***-***-4567
SELECT mask_ssn('123-45-6789');
-- Returns: ***-**-6789
SELECT mask_text('sensitive data', 2, 2);
-- Returns: se**********ta
SELECT mask_ip('192.168.1.100');
-- Returns: 192.168.xxx.xxx
Running Tests
pgrx includes a test framework that runs tests inside a real PostgreSQL instance:
#[cfg(any(test, feature = "pg_test"))]
#[pg_schema]
mod tests {
use super::*;
#[pg_test]
fn test_mask_email() {
assert_eq!(
mask_email("john.doe@example.com"),
"j*******@example.com"
);
}
#[pg_test]
fn test_mask_card() {
assert_eq!(
mask_card("4111111111111111"),
"****-****-****-1111"
);
}
}
Run tests with:
cargo pgrx test pg18
Packaging for Production
cargo pgrx package
This creates a distributable package in target/release/pg_mask-pg18/ containing:
The compiled shared library
SQL installation scripts
The control file
Common Gotchas
1. Control File Name = Extension Name
The .control file must match your extension name (not necessarily the crate name):
Extension name:
pg_maskControl file:
pg_mask.control
2. The pgrx_embed Binary is Required
The embedding binary is required for SQL generation. Without it, cargo pgrx commands will fail:
// src/bin/pgrx_embed.rs
::pgrx::pgrx_embed!();
And referenced in Cargo.toml:
[[bin]]
name = "pgrx_embed_pg_mask" # Must be pgrx_embed_<extension_name>
path = "src/bin/pgrx_embed.rs"
3. macOS Needs Special Linker Flags
On macOS, you must add -undefined dynamic_lookup linker flags in .cargo/config.toml. Without these, linking fails with undefined symbol errors for PostgreSQL functions.
4. Must Run cargo pgrx init Before First Build
Before your first build, initialize pgrx:
cargo pgrx init --pg18=$(which pg_config)
This sets up PostgreSQL headers and the build environment. Skip this and you'll get cryptic build errors.
5. PostgreSQL Version Must Match Feature Flag
Your feature flag must match your target PostgreSQL version:
[features]
default = ["pg18"] # Must match your PostgreSQL installation
pg15 = ["pgrx/pg15", "pgrx-tests/pg15"]
pg16 = ["pgrx/pg16", "pgrx-tests/pg16"]
pg17 = ["pgrx/pg17", "pgrx-tests/pg17"]
pg18 = ["pgrx/pg18", "pgrx-tests/pg18"]
If you're targeting PostgreSQL 16 but have default = ["pg18"], you'll get version mismatch errors at runtime.
Beyond Simple Functions
pg_mask demonstrates simple scalar functions, but pgrx supports much more:
Custom Types
#[derive(PostgresType, Serialize, Deserialize)]
pub struct Point {
x: f64,
y: f64,
}
Aggregate Functions
#[pg_aggregate]
impl Aggregate for MySum {
type State = i64;
type Args = i32;
// ...
}
Background Workers
#[pg_guard]
pub extern "C" fn my_worker_main(_arg: pg_sys::Datum) {
// Runs in background
}
Server Programming Interface (SPI)
#[pg_extern]
fn count_rows(table_name: &str) -> i64 {
Spi::connect(|client| {
let query = format!("SELECT COUNT(*) FROM {}", table_name);
client.select(&query, None, None)?
.first()
.get_one::<i64>()?
.unwrap_or(0)
})
}
Conclusion
Building PostgreSQL extensions with Rust and pgrx is genuinely enjoyable. You get:
Memory safety: No more debugging segfaults at 3 AM
Modern tooling: Cargo, crates.io, rust-analyzer
Type safety: The compiler catches mistakes before they hit production
Performance: Rust compiles to native code, matching C speed
The pg_mask extension we walked through is ~100 lines of Rust. A C equivalent would be 300+ lines, riddled with manual memory management and error handling boilerplate.
If you've been hesitant to write PostgreSQL extensions because of C's complexity, give pgrx a try. The learning curve is gentler than you might expect, especially if you already know Rust.
Source Code
Full source code for pg_mask: github.com/jatin510/postgres-ext-exp
Resources
Ideas for Your First Extension
Data validation functions: Email formats, phone numbers, URLs
Text processing: Fuzzy matching, phonetic encoding, slugification
Encryption wrappers: Field-level encryption with key management
External API integrations: HTTP clients, queue publishers
Custom index types: Specialized data structures for your domain
The PostgreSQL extension ecosystem needs more Rust. Go build something.




