PII redaction in Postgres today works column by column. You can revoke a role's access to a column, or use an extension like PostgreSQL Anonymizer to attach a masking rule to it with SECURITY LABEL, so customers.email or customers.ssn are masked for anyone who isn't cleared to see them. Its limitation is that it only works when each piece of personal data has its own column, which is rarely the case with free text. Take this support ticket for example: "Please credit invoice 88213 back to Daniel Brooks at daniel.brooks@acme.co, whose SSN on file is 402-11-9931." It's stored in a single body text column, so a column rule can either hide the whole message or show all of it, including the name, the email, and the SSN.
Running regex over the text doesn't get you much further. A pattern that flags every run of digits would end up masking the invoice number along with the SSN, even though the support agent needs the invoice number to issue the refund. Whether a snippet is personal data depends on what it means in the sentence, so you need something that reads the text in its own context.
First, you need to find the snippets that could be PII, and I simply used regex for emails and digit runs, a rule to pick up runs of capitalized words (which could be a name), another for number-led runs that might be an address. These rules do not decide what counts as PII and they only suggest candidates for Jev to review.
Deciding which of those candidates are PII is a separate job, and that's where Jev comes in. Jev is TypeSafe's classification model. You give it the text and a set of typed questions, and it returns a typed answer with a probability for each one. For every candidate, pg_redact asks Jev whether it's personal data and, if so, what kind, and Jev answers with a label such as name, email, or none.
Once you have the labels, you need to store them next to the text they describe and check them every time someone reads a message. Postgres already holds the message, so it's a natural place for both. And since the app already fetches every message from Postgres, you can write the masking rule once as a SQL function, and any route that loads a message can apply it.
To test this out, I built pg_redact. It's a support inbox with three roles (Guest, Support Agent, Admin) - depending on your role, the support messages show up more or less anonymized:
- Guest sees no PII (every detected identifier is sealed)
- Support Agent sees names, emails, and phone numbers (addresses and IDs stay sealed)
- Admin sees everything
The live demo: https://pg-redact.vercel.app
Under the hood, pg_redact has three pieces:
- Candidate generation, which uses simple regex and tokenization rules to find snippets that could be PII
- Jev, which decides for each snippet whether it's personal data, and what kind, with a calibrated probability
- Neon, which stores each message and its classified spans as JSONB, with a redact() SQL function that seals or reveals each snippet based on the viewer's role
In this post, I'll walk you through each piece of the pg_redact's architecture. You can check out all the code here: https://github.com/rishi-raj-jain/pg-redact.
Before Jev can classify anything, pg_redact needs a list of snippets. generateCandidates() builds that list in a few passes, starting with regex for structured shapes (source):
It then tokenizes the message and adds runs of capitalized words that could be a name or a place (like "Daniel Brooks"), runs starting with a number that could be a street address, and backfills with any other tokens, up to 40 candidates per message. This step never decides what's PII. In the support ticket above, both 88213 and 402-11-9931 match a pattern, so both go to Jev as candidates.
Each candidate then goes to Jev. Jev is TypeSafe's System One model, where you send a state (here, the message text) along with a set of typed questions, and get typed answers with calibrated probabilities back in a single API response.
The labels Jev can pick from are defined once in JEV_CRITERIA, with a short description for each option that Jev reads along with the question:
classify() then asks one choice question per candidate snippet, and Jev evaluates all of them in the same request:
Jev answers each question under the same key it was asked with (q0, q1, and so on). Every answer carries the winning choice and a probability for each option (source):
pg_redact then turns those answers into snippets. It skips anything that is labeled as none, and only keeps a snippet when the winning probability is at least 0.5. This filters out the low-confidence guesses before anything is written to Postgres (source):
For the Daniel Brooks ticket, these are the snippets Jev labeled and pg_redact stored in the demo's database:
The invoice number 88213 went to Jev as a candidate but didn't come back as PII, so it isn't stored and is readable by every role. Jev also labeled the word "SSN" itself as an id_number, so a Guest or a Support Agent doesn't see which kind of identifier is next to it.
With the labels ready, pg_redact needs a place to store them and a rule that uses them. npm run db:setup creates the messages table, where each message keeps its snippets in a JSONB column, in the same row as the text:
When someone pastes a new message in the demo, the classify route ties the three steps together. It generates candidates, sends them to Jev, and inserts the message along with its snippets in one statement (error handling trimmed):
Each snippet has the shape , i.e. the exact substring, the label Jev picked, and its confidence. pg_redact then uses two SQL functions (pii_sensitivity() and redact()) to decide what each role can see.
The first one, pii_sensitivity(), gives each PII type a rank:
Each role then gets a clearance level to compare against that rank (source):
A viewer can see a snippet when their clearance is greater than or equal to that snippet's sensitivity.
The second function, redact(), does that check. It goes through the stored snippets from longest to shortest and blacks out any snippet whose sensitivity is above the caller's clearance:
You can run redact() yourself in the Neon SQL Editor. Here's the Daniel Brooks ticket at each clearance level:
Because redact() replaces the longest snippets first, "Daniel Brooks" becomes one 13-character bar before the shorter "Daniel" and "Brooks" entries can split it. And since storing the labels and enforcing them are separate steps, you can change a role's clearance or a PII type's sensitivity without requiring reclassification.
Proving that enforcement lives in the database
A demo like this could mask values in the API response and keep the rule in application code. The reveal route is there so you can see what Postgres itself computes. It takes a message ID and a role, and returns the string redact() produced for that role:
In the demo, expand what Postgres returns for this role under any message. When you switch roles, the app calls /api/reveal with the new clearance and shows you the string Postgres returns.
You can run it locally:
You can also open the demo and try switching roles yourself. If you're building something similar, point your coding agent at the repository and reuse the same setup in your own app.