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 reply comes in.
Why I built this
Back when generative AI started taking off, around 2023 and 2024, a senior on my team got worried about personal data. People were pasting real customer details into these models just to get work done, and he asked me to find a way to handle it.
So I looked at what was already out there. The free libraries could mask data but they couldn't put it back. The ones that did both were paid. I needed both halves: hide the data on the way to the model, and restore it in the answer. So I read up on how the paid tools worked and wrote my own.
I built most of this back then and it sat in a notebook. I finally got some time to clean it up and turn it into a proper package, so here it is.
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 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 the real thing.
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 fast 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))
The same value always maps to the same token within a session, so you can run a whole multi-turn conversation through one Anonymizer and the model keeps treating <EMAIL_1> as the same person across every message.
What it detects
Out of the box, piimask picks up 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 to 15 digits, ISO dates excluded |
CREDIT_CARD |
4111 1111 1111 1111 |
checked with the Luhn algorithm |
SSN |
123-45-6789 |
US format |
IBAN |
GB82 WEST 1234 5698 7654 32 |
checked with the ISO 13616 mod-97 sum |
IP_ADDRESS |
192.168.1.20 |
strict 0 to 255 octets |
IPV6 |
2001:db8::1 |
|
URL |
https://acme.com/x |
trailing punctuation is left alone |
Need something else, like an employee ID, a policy number, or an internal hostname? You can add a recognizer in one line (see below).
Masking strategies
The strategy decides how a detected value gets replaced. Three are reversible, and one is one-way for cases where you never want the value back.
| Strategy | Reversible | Example output | Best for |
|---|---|---|---|
placeholder |
yes | <EMAIL_1> |
LLM round-trips (the default) |
fake |
yes | kevin83@hotmail.com |
prompts that behave better on realistic input |
hash |
yes (*) | <EMAIL_9f2c1a2b3c> |
stable tokens for dedup and joining logs |
partial |
no | j***@***.com |
human-readable previews and support UIs |
Anonymizer(strategy="partial").mask("ssn 123-45-6789")
# "ssn ***-**-6789"
anon = Anonymizer(strategy="hash", salt="pepper")
masked = anon.mask("a@b.com and a@b.com")
# both copies get the same token, e.g.
# "<EMAIL_9f2c1a2b3c> and <EMAIL_9f2c1a2b3c>"
anon.unmask(masked) # "a@b.com and a@b.com", restored from the vault
The hash strategy is deterministic: the same input always produces the same token, even in a fresh session, so you can count distinct users or join two logs on the token. It's reversible too, but only while you keep the vault. Each token is stored as token -> original when you mask, so unmask can restore it locally. Throw the vault away and the tokens are effectively one-way, which is what you want for logs you never need to turn back into real data.
(*) hash is reversible only while you hold the vault.
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
Warning: a vault holds 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 have to 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)
Keep the key somewhere separate from the blob. Anyone who has both can recover the data.
Custom recognizers
Anything you can describe with a regex, you can mask. Give it a name and, if you want, 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?
Good, but 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 has no dependencies, but it matches patterns, not meaning. It won't catch a person's name written in prose, a mailing address, or some new identifier it has never seen.
If you need that kind of detection (names, locations, organizations), pair piimask with a named-entity-recognition model and register the results as custom detections. The vault and unmasking work exactly the same. Treat this library as a strong first layer, not a guarantee, and 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 calls of any kind. The vault lives in memory unless you serialize it yourself.
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 or 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.2.0.tar.gz.
File metadata
- Download URL: piimask-0.2.0.tar.gz
- Upload date:
- Size: 24.6 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 |
6d3271f73ff32fb0d8a1be301a2e61d7d9adf86c4ef203e3fe064cbbe7c3361c
|
|
| MD5 |
2ecdf19b0e1977d805d41579f9b308f6
|
|
| BLAKE2b-256 |
4f3e2baf1437a76760d6b0a41caf3ba7f297fc2b85961ab66deb0b5f03ff7c58
|
File details
Details for the file piimask-0.2.0-py3-none-any.whl.
File metadata
- Download URL: piimask-0.2.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 |
d3f7dd70c5d6ac743d2bad164528b5a75b67f8b7a860cb200fa4c9ba28d84005
|
|
| MD5 |
54411c1e60e4493d2b3885ea04ec78de
|
|
| BLAKE2b-256 |
09018ff529e5e0571c3f279361103eafbb7dc8abf32a912d82717d99e891d30e
|