Skip to main content

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.

PyPI Python License: MIT

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 .gitignore already 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

piimask-0.2.0.tar.gz (24.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

piimask-0.2.0-py3-none-any.whl (21.5 kB view details)

Uploaded Python 3

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

Hashes for piimask-0.2.0.tar.gz
Algorithm Hash digest
SHA256 6d3271f73ff32fb0d8a1be301a2e61d7d9adf86c4ef203e3fe064cbbe7c3361c
MD5 2ecdf19b0e1977d805d41579f9b308f6
BLAKE2b-256 4f3e2baf1437a76760d6b0a41caf3ba7f297fc2b85961ab66deb0b5f03ff7c58

See more details on using hashes here.

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

Hashes for piimask-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 d3f7dd70c5d6ac743d2bad164528b5a75b67f8b7a860cb200fa4c9ba28d84005
MD5 54411c1e60e4493d2b3885ea04ec78de
BLAKE2b-256 09018ff529e5e0571c3f279361103eafbb7dc8abf32a912d82717d99e891d30e

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 files

0.1.0

2 files

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page