piimask
Reversible PII masking and unmasking for the LLM era. Hide personal data before it leaves your machine — send the safe version to a model, an API, or a log — then put the real values back when the answer comes home.
Why this exists
A few years ago, "sending your data to the cloud" meant a database you controlled. Today it means pasting a customer's email, a patient's phone number, or a colleague's home address into a prompt and shipping it off to a large language model you don't own, running somewhere you can't see, that may log or train on what it receives.
Most of the time we don't even notice we're doing it. A support ticket gets summarized. A sales call gets turned into follow-up notes. A spreadsheet gets "cleaned up." Each of those helpful little automations quietly carries real people's personal information across a boundary it was never meant to cross.
piimask is a small, dependency-free library that puts a checkpoint at that boundary. It finds the personal data in a piece of text, swaps it for stable placeholders, and remembers the mapping so you can reverse it later. The model sees <EMAIL_1> instead of jane@acme.com — and does its job just as well — while the real value never leaves your process.
It won't make you compliant with GDPR or HIPAA on its own, and it isn't a replacement for good security hygiene. But it makes the safe thing the easy thing, which is usually where privacy succeeds or fails.
The idea in ten seconds
from piimask import Anonymizer
anon = Anonymizer()
safe = anon.mask("Hi, I'm Jane — email jane@acme.com or call +1 415-555-0132.")
# "Hi, I'm Jane — email <EMAIL_1> or call <PHONE_1>."
# ...send `safe` to any LLM / API / log sink you don't fully trust...
answer = "I've emailed <EMAIL_1> and left a voicemail at <PHONE_1>."
print(anon.unmask(answer))
# "I've emailed jane@acme.com and left a voicemail at +1 415-555-0132."
Mask on the way out. Unmask on the way in. The model works with tokens; you work with reality.
Install
# with uv (recommended)
uv add piimask
# or with pip
pip install piimask
Optional extras:
uv add "piimask[faker]" # realistic fake-data masking
uv add "piimask[crypto]" # encrypt the vault at rest
uv add "piimask[all]" # both
The core library has zero required dependencies — it's pure Python standard library, so it installs instantly and runs anywhere Python 3.9+ does.
A real workflow: protecting an LLM call
from piimask import Anonymizer
# from openai import OpenAI # or anthropic, or anything else
anon = Anonymizer()
client = OpenAI()
user_message = "Summarize this: Jane Doe (jane@acme.com, +1 415-555-0132) " \
"disputes charge on card 4111 1111 1111 1111."
# 1. Mask before the data leaves your machine.
safe_message = anon.mask(user_message)
# 2. The model only ever sees tokens.
reply = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": safe_message}],
).choices[0].message.content
# 3. Restore the real values in the model's answer.
print(anon.unmask(reply))
Because the same value always maps to the same token within a session, you can feed a whole multi-turn conversation through one Anonymizer and the model will reason about <EMAIL_1> consistently across every message.
What it detects
Out of the box, piimask recognizes the kinds of PII that leak most often into prompts and logs:
| Entity | Examples | Notes |
|---|---|---|
EMAIL |
jane.doe+tag@sub.acme.co.uk |
|
PHONE |
+1 (415) 555-0132, 07700 900123 |
7–15 digits; ISO dates are excluded |
CREDIT_CARD |
4111 1111 1111 1111 |
validated with the Luhn checksum |
SSN |
123-45-6789 |
US format |
IBAN |
GB82 WEST 1234 5698 7654 32 |
validated with the ISO 13616 mod-97 sum |
IP_ADDRESS |
192.168.1.20 |
strict 0–255 octets |
IPV6 |
2001:db8::1 |
|
URL |
https://acme.com/x |
trailing punctuation is left alone |
Need something else — an employee ID, a policy number, an internal hostname? Add a recognizer in one line (see below).
Masking strategies
The strategy decides how a detected value is replaced. Two are reversible; two are one-way, which is exactly what you want for logs you never need to rehydrate.
| Strategy | Reversible | Example output | Best for |
|---|---|---|---|
placeholder |
✅ | <EMAIL_1> |
LLM round-trips (the default) |
fake |
✅ | kevin83@hotmail.com |
prompts that behave better on realistic input |
partial |
❌ | j***@***.com |
human-readable previews & support UIs |
hash |
❌ | <EMAIL_c066add4a0> |
logs & analytics — correlate without exposing |
Anonymizer(strategy="partial").mask("ssn 123-45-6789")
# "ssn ***-**-6789"
Anonymizer(strategy="hash", salt="pepper").mask("a@b.com and a@b.com")
# "<EMAIL_9f2c...> and <EMAIL_9f2c...>" # same input → same token, never reversible
hash is deliberately consistent: the same input always produces the same token, so you can still count distinct users or join two logs together — you just can never get the email back.
The vault: where reversibility lives
Every reversible mask records a token → original entry in a vault. That's what unmask reads from.
anon = Anonymizer()
anon.mask("contact jane@acme.com")
anon.vault.to_json() # persist the mapping (e.g. between requests)
len(anon.vault) # how many values are stored
anon.reset() # forget everything the moment you're done
⚠️ A vault contains the real PII in cleartext. Treat it like a password. Keep it in memory for the life of a request when you can, never commit one to source control (the shipped
.gitignorealready blocks*.vault), and if you must store it, encrypt it:
from piimask import Vault
key = Vault.generate_key() # needs: pip install "piimask[crypto]"
blob = anon.vault.to_encrypted_json(key) # ciphertext bytes, safe to store
restored = Vault.from_encrypted_json(blob, key)
Store the key somewhere separate from the blob — anyone holding both can recover the data.
Custom recognizers
Anything you can describe with a regex, you can mask. Give it a name, and (optionally) a validator to reject false positives.
from piimask import Anonymizer, Recognizer
anon = Anonymizer()
anon.add_recognizer(Recognizer("EMPLOYEE_ID", r"\bEMP-\d{5}\b", priority=5))
anon.mask("ticket from EMP-12345")
# "ticket from <EMPLOYEE_ID_1>"
Lower priority numbers win when matches overlap, so a specific pattern can take precedence over a general one.
Command line
piimask installs a small CLI for quick masking and pipelines:
echo "email me at a@b.com" | piimask
# email me at <EMAIL_1>
piimask --text "call 415-555-0100" --strategy partial
piimask --detect --text "ssn 123-45-6789" # prints detections as JSON
Functional API
Prefer plain functions? Every capability has a one-shot form:
from piimask import mask, unmask, detect
masked, vault = mask("hi jane@acme.com")
original = unmask(masked, vault)
found = detect("card 4111 1111 1111 1111") # -> [Detection(entity_type='CREDIT_CARD', ...)]
How accurate is detection?
Honestly: good, not perfect. piimask uses well-tuned regular expressions plus checksums (Luhn for cards, mod-97 for IBANs) to keep false positives low. That approach is fast, transparent, and dependency-free — but it recognizes patterns, not meaning. It will not catch a person's name written in prose, a mailing address, or a novel identifier it has never seen.
If you need semantic detection (names, locations, organizations), pair piimask with a named-entity-recognition model and register the results as custom detections — the vault and unmasking machinery work exactly the same. Treat this library as a strong, reliable first layer, not as a guarantee. Always keep a human in the loop for anything high-stakes.
FAQ
Does the masked data ever leave my machine?
Only if you send it somewhere. piimask does no network I/O of any kind. The vault lives in memory unless you explicitly serialize it.
Will the placeholders confuse the LLM?
Rarely. Models handle <EMAIL_1> tokens well and keep them consistent. If a particular model does better with natural-looking input, switch to the reversible fake strategy so it sees a plausible email that still maps back to the real one.
Is this enough for GDPR / HIPAA compliance?
No single library makes you compliant. piimask is a practical control that reduces exposure; compliance is about your whole system, your contracts, and your processes. Use it as one layer of several.
Contributing
Issues and pull requests are welcome. To set up locally:
uv sync --all-extras
uv run pytest
License
MIT — see LICENSE. Built by Vipul Parmar.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file piimask-0.1.0.tar.gz.
File metadata
- Download URL: piimask-0.1.0.tar.gz
- Upload date:
- Size: 24.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.12.3 {"installer":{"name":"uv","version":"0.12.3","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"22.04","id":"jammy","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
615d2c06f66cc4d4bb067bd5006e12f6f59975ef484a70b4b28f8f11c4a6b254
|
|
| MD5 |
d57c6c6cf4fb84636077bf618c950a48
|
|
| BLAKE2b-256 |
5b1128a4adfbfb544be289a0b11eed3502a8eeb17b4878cf5952e1922889be30
|
File details
Details for the file piimask-0.1.0-py3-none-any.whl.
File metadata
- Download URL: piimask-0.1.0-py3-none-any.whl
- Upload date:
- Size: 21.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.12.3 {"installer":{"name":"uv","version":"0.12.3","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"22.04","id":"jammy","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5a498dc70eab8c4a854bf70e0747705367a2b0d17881961ee377d4b4e4e098ad
|
|
| MD5 |
3806009d6dc8a2e7fc0950acec7ce2d3
|
|
| BLAKE2b-256 |
4ea6119772cb4b30e13399ba016cc654388f80956b6c8f30f4737ca328df8dcd
|