January 7, 2025·5 min read·engineeringpostgresqlprivacyextensions

PostgreSQL Anonymizer 2.0 changes how masked data leaves the database

Anonymizer 2.0 combines a Rust implementation with role-based masking and ordinary pg_dump exports. Its upgrade and masking semantics deserve as much attention as the rewrite.

Copying production data into a less restricted environment creates a second access-control problem. A masked query against the original database does not by itself describe what an exported copy contains. PostgreSQL Anonymizer 2.0 addresses that connection through transparent role-based masking and exports made with ordinary pg_dump. Dalibo's January announcement also describes a complete rewrite in Rust using PGRX. The useful engineering question is how those changes affect the path between a declared masking rule and the data somebody receives.

PostgreSQL remains my default relational database, partly because its extension interface permits substantial capabilities to live alongside the engine. Anonymizer is an example of that flexibility. Privacy rules become database metadata, and the extension supplies the machinery that interprets them. This is a design preference, not a claim that attaching a label proves a dataset anonymous.

A role determines which values a reader receives

The version 2.0 dynamic-masking documentation distinguishes a masked role from other roles that still receive original values. A column's security label specifies its replacement expression. A separate label marks the role as masked, while ordinary grants determine whether that role can read the table. These are separate decisions, so declaring a role masked does not replace access control.

For an existing people table with a text phone column, the documented partial-mask rule uses a function expression. The security label names the column that the policy transforms:

SECURITY LABEL FOR anon ON COLUMN people.phone
IS 'MASKED WITH FUNCTION anon.partial(phone,2,$$******$$,2)';

The example retains two characters at each end and substitutes the middle. That makes its information loss explicit. A recipient still learns the retained characters, so this rule suits a requirement that permits partial disclosure. Replacing a value and removing all identifying information are different requirements that the policy author must resolve.

Transparent masking and the legacy dynamic-masking implementation cannot run together. The same documentation tells upgrading users to stop legacy masking with anon.stop_legacy_dynamic_masking(). This makes the engine choice part of the migration, even when existing column labels remain suitable.

An export is another masked read

The versioned anonymous-dump guide uses a dedicated masked role to run pg_dump. That role has transparent dynamic masking enabled and receives read permissions. The resulting workflow applies the database's masking policy while exporting, avoiding a separate export utility with its own interpretation of that policy.

The guide also removes security labels from the export and excludes the extension. Its example requires pg_dump 17 or later for --exclude-extension:

pg_dump foo \
  --user anon_dumper \
  --no-security-labels \
  --exclude-extension=anon \
  --file=foo_anonymized.sql

Those options describe the intended recipient environment. It receives transformed data without the source masking policy or the extension declaration. The guide explains that masked users should not have access to the policy itself. Custom-format exports are also supported by this method, while the older pg_dump_anon utility remains for compatibility and is deprecated.

I prefer this arrangement because it makes the exporting identity part of the privacy decision. A command using an unrestricted role has a different meaning even if its filename says anonymized. The rule lives in the database, but the chosen connection determines whether the export applies it.

Permanent transformation has a different cost

The static-masking guide describes a destructive operation. anon.anonymize_database() applies rules to stored values, while narrower functions target a table or column. A restricted reader no longer determines whether a transformed value appears, because the transformation replaces the source data in that database.

The guide explains the associated expense. Static masking updates rows in tables containing masked columns and can rewrite substantial data on disk. Exporting masked values and loading them elsewhere may be faster, depending on database size and configuration. These workflows therefore differ in both where the original survives and where the transformation work occurs.

Shuffling has another distinct meaning. It rearranges values within a column and requires a primary key argument. The documentation separates it from row-level masking functions because it operates across the column. That distinction matters when choosing a transformation to preserve useful relationships. An expression that independently invents each replacement does not automatically provide the same properties as rearranging an existing set of values.

A plausible replacement can still disclose information

The masking-function reference gives the Rust change a concrete application. New dummy_ functions use a Rust fake-data library and support localization. The older fake_ functions remain for compatibility and use an initialized dataset. The reference recommends the newer functions for new users.

Plausibility and confidentiality still require separate reasoning. The same reference warns that repeatedly querying a value with random noise can reveal an approximation of the original through averaging. It recommends those noise functions for static masking or anonymous exports instead of dynamic masking. This is a property of repeated observations, so changing the implementation language does not remove it.

That distinction is why I find the export work more interesting than a general claim about safer implementation. A recipient with one transformed copy has different opportunities from a role that can request fresh noisy answers indefinitely. Choosing the method changes the information available to the recipient, even when both methods invoke the same named function.

The rewrite does not provide an in-place extension upgrade

The 2.0 upgrade guide says that ALTER EXTENSION ... UPDATE is unsupported. Upgrades require dropping and recreating the extension. It also says that most public interfaces remain unchanged and that a policy written for 1.3 should work with 2.0. Policy compatibility therefore does not imply an in-place installation procedure.

The installation guide further distinguishes deploying the library, loading it into PostgreSQL sessions, and creating the extension within a database. Its session-preload method requires a new connection before the setting takes effect. An installation can have the package on disk while a session still lacks the loaded library.

These details define the practical scope of the January announcement. Rust changes the implementation, while transparent masking connects database roles to ordinary export tooling. Existing deployments still need an explicit extension replacement and a deliberate transition away from legacy masking. The exported values ultimately depend on the active policy and the role that reads them.