Skip to main content

Command Palette

Search for a command to run...

Building PostgreSQL Extensions with Rust: A Complete Guide Using pgrx

A practical guide to pgrx with a real-world data masking example

Updated
10 min read
Building PostgreSQL Extensions with Rust: A Complete Guide Using pgrx
J

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:

ProblemCRust
Memory safetyManual, error-proneCompiler-enforced
Null pointer bugsRuntime crashesCompile-time prevention
Package managementCopy-paste codeCargo + crates.io
TestingRoll your ownBuilt-in test framework
Error handlingMacros + longjmpResult 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 \dx listings

  • default_version: pgrx replaces @CARGO_VERSION@ with your Cargo.toml version

  • module_pathname: Where PostgreSQL looks for your shared library

  • superuser = 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_maskpg_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:

  1. Makes the function callable from SQL

  2. Generates the SQL CREATE FUNCTION statement

  3. Handles type conversions between PostgreSQL and Rust

  4. 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 TypePostgreSQL Type
&strTEXT
StringTEXT
i32INTEGER
i64BIGINT
f64DOUBLE PRECISION
boolBOOLEAN
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:

  1. Compiles your extension

  2. Starts a temporary PostgreSQL server

  3. Connects you to a psql session

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_mask

  • Control 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.