Try Chat Free Docs Pricing AI Analysis Repo Insights Free Tools About Blog
Sign in Get started free
< Back to blog
Guide Velona Team ·22 October 2026 ·21 min read

How to Anonymize Sensitive Data Before Sending It to an LLM: A Python Tutorial for Privacy-Preserving AI

SECURITY GUIDE · PYTHON · OCTOBER 2026 Anonymize before the LLM Mask personal data in Python, call the model, restore the values Presidio, Aadhaar, PAN, UPI, streaming, tool calls, DPDP your server Priya Sharma98765 43210 PERSON_1PHONE_1 LLM API reply restored locally Dear Priya Sharma ...

To anonymize sensitive data before sending it to an LLM, detect personal data on your own server, replace each value with a consistent placeholder such as <PERSON_1>, send only the masked text to the model, then swap the real values back into the reply before your user sees it. The model still understands the task, because "refund for <PERSON_1> to <UPI_ID_1>" carries the same meaning, but the provider, its logs and your own prompt logs never see the name or the UPI ID.

This guide builds that layer in Python with Presidio, the open source PII detection library created by Microsoft. It covers Indian identifiers that generic tools miss, including Aadhaar, PAN, GSTIN, UPI IDs and Indian mobile numbers, plus streaming replies, tool calls and a fail closed leak check. Every snippet was run before publishing, and masking a typical support message took about 11 milliseconds on a CPU in our tests.

₹250 crore

maximum DPDP Act penalty for failing to take reasonable security safeguards against a personal data breach

~11 ms

median time to mask a 134 character support message with Presidio and spaCy on a CPU, in our test

13

entity types masked by the code below, including Aadhaar, PAN, GSTIN, voter ID and UPI ID

Why anonymize data before sending it to an LLM?

When you call a hosted model, your prompt leaves your infrastructure. Even with a provider that does not train on API data, the text may be retained for abuse monitoring, may show up in your own request logs, traces and error reports, and may be echoed back in a later answer if you reuse context. OWASP's Top 10 for LLM Applications lists Sensitive Information Disclosure as risk LLM02 for 2025 for exactly these reasons.

In India there is also a legal reason. The Digital Personal Data Protection Rules, 2025 were notified on 13 and 14 November 2025, with most obligations, including reasonable security safeguards, coming into force 18 months later, in May 2027. The Act's principles include data minimisation, and the highest penalty tier, up to ₹250 crore, applies to failing to take reasonable security safeguards. Not sending personal data you do not need to a third party is one of the simplest safeguards to show. This guide is engineering advice, not legal advice, so confirm your obligations with whoever owns compliance.

Without maskingRaw PII leaves

Names, phone numbers, Aadhaar and UPI IDs go to the model provider and into every log, trace and cache your prompt touches.

With maskingOnly placeholders leave

The model sees <PERSON_1> and <UPI_ID_1>. The mapping to real values stays in memory on your server for the length of one request.

Redaction, pseudonymization or anonymization: which one do you need?

Techniques

Four ways to hide personal data from a model

For live LLM calls, reversible placeholders give the best balance of privacy and usefulness

TechniqueExample outputReversible?Best for
Redaction[REDACTED]NoLogs, analytics, training data
Placeholder pseudonymization<PERSON_1>Yes, with a vaultChatbots, support drafting, summaries, agents
Synthetic replacementA fake name from FakerYes, with a vaultPrompts where realistic text matters
Hashing or encryptiona3f9…Only with a keyJoining records without exposing them

Strictly, text you can reverse is pseudonymized, not anonymized. Keep the vault inside your system and short lived, and the provider only ever holds pseudonymous text.

Placeholders beat plain redaction because they keep identity consistent. If a customer's name appears three times, all three become <PERSON_1>, so the model can still tell people apart and write "Dear <PERSON_1>". Redaction turns every value into the same [REDACTED] and the model loses track.

How the privacy layer works

DetectPresidio finds entities with regular expressions, checksums such as Verhoeff for Aadhaar, context words and a spaCy named entity model for names and places.

ReplaceEach value becomes a typed, numbered placeholder. The same value always gets the same placeholder within a request.

CheckA second scan of the masked text blocks the request if a high confidence entity survived.

SendOnly masked text goes to the model, with a system prompt telling it to copy placeholders exactly.

RestorePlaceholders in the reply, streamed text and tool call arguments are swapped back to real values on your server.

ForgetThe vault lives for one request. Logs store the masked prompt only.

Step 0: Install Presidio and a spaCy model

pip install presidio-analyzer presidio-anonymizer openai
python -m spacy download en_core_web_lg
export VELONA_API_KEY=your_key

Use en_core_web_lg in production. In our tests the small model, en_core_web_sm, missed "Ananya Iyer from Chennai" completely, while the large model caught both the name and the city at the same speed, about 11 ms per message. The large model is a download of several hundred megabytes, so size your container memory for it.

Step 1: Build the PII detector and the vault

This module does three jobs. It builds a Presidio analyzer with India specific recognizers switched on, it defines a PIIVault that hands out stable placeholders and restores them, and it wraps both in a PrivacyGuard with a fail closed leak check.

"""privacy_guard.py: reversible PII pseudonymization for LLM calls."""
import re
from presidio_analyzer import AnalyzerEngine, Pattern, PatternRecognizer
from presidio_analyzer.nlp_engine import NlpEngineProvider
from presidio_analyzer.predefined_recognizers import (
    InAadhaarRecognizer, InGstinRecognizer, InPanRecognizer,
    InPassportRecognizer, InVoterRecognizer,
)
from presidio_anonymizer import AnonymizerEngine
from presidio_anonymizer.entities import OperatorConfig

SPACY_MODEL = "en_core_web_lg"  # use en_core_web_sm if memory is tight

# Entities we always mask. Add or remove to match your data.
ENTITIES = [
    "PERSON", "EMAIL_ADDRESS", "PHONE_NUMBER", "CREDIT_CARD", "IBAN_CODE",
    "IP_ADDRESS", "LOCATION", "IN_AADHAAR", "IN_PAN", "IN_GSTIN",
    "IN_PASSPORT", "IN_VOTER", "UPI_ID",
]


def build_analyzer(model: str = SPACY_MODEL) -> AnalyzerEngine:
    provider = NlpEngineProvider(nlp_configuration={
        "nlp_engine_name": "spacy",
        "models": [{"lang_code": "en", "model_name": model}],
    })
    analyzer = AnalyzerEngine(nlp_engine=provider.create_engine(),
                              supported_languages=["en"])

    # India recognizers ship with Presidio but are disabled by default
    for rec in (InAadhaarRecognizer(), InPanRecognizer(), InGstinRecognizer(),
                InPassportRecognizer(), InVoterRecognizer()):
        analyzer.registry.add_recognizer(rec)

    # UPI IDs (name@bank). The lookahead skips emails like [email protected]
    upi = PatternRecognizer(
        supported_entity="UPI_ID",
        patterns=[Pattern("upi_id", r"\b[\w.\-]{2,256}@[a-zA-Z]{2,64}\b(?!\.)", 0.6)],
        context=["upi", "vpa", "gpay", "phonepe", "paytm", "pay"],
    )
    analyzer.registry.add_recognizer(upi)

    # Indian mobile numbers: the generic phone recognizer scores them low
    in_mobile = PatternRecognizer(
        supported_entity="PHONE_NUMBER",
        name="InMobileRecognizer",
        patterns=[Pattern("in_mobile",
                          r"(?<!\d)(?:\+?91[\s-]?|0)?[6-9]\d{4}[\s-]?\d{5}(?!\d)", 0.7)],
        context=["phone", "mobile", "call", "whatsapp", "contact"],
    )
    analyzer.registry.add_recognizer(in_mobile)
    return analyzer


class PIIVault:
    """Maps real values to stable placeholders like <PERSON_1>, per request."""

    TOKEN_RE = re.compile(r"<\s*([A-Z][A-Z_]*_\d+)\s*>")

    def __init__(self):
        self._forward = {}   # (entity, value) -> token
        self._reverse = {}   # token -> value
        self._counts = {}

    def token_for(self, entity: str, value: str) -> str:
        key = (entity, value.strip())
        if key not in self._forward:
            self._counts[entity] = self._counts.get(entity, 0) + 1
            token = f"<{entity}_{self._counts[entity]}>"
            self._forward[key] = token
            self._reverse[token] = value
        return self._forward[key]

    def restore(self, text: str) -> str:
        def swap(m):
            token = f"<{m.group(1)}>"
            return self._reverse.get(token, m.group(0))
        return self.TOKEN_RE.sub(swap, text)


class PrivacyGuard:
    def __init__(self, analyzer: AnalyzerEngine | None = None,
                 entities=ENTITIES, threshold: float = 0.5):
        self.analyzer = analyzer or build_analyzer()
        self.anonymizer = AnonymizerEngine()
        self.entities = entities
        self.threshold = threshold

    def find(self, text: str):
        return self.analyzer.analyze(text=text, language="en",
                                     entities=self.entities,
                                     score_threshold=self.threshold)

    def anonymize(self, text: str, vault: PIIVault) -> str:
        results = self.find(text)
        operators = {
            r.entity_type: OperatorConfig(
                "custom", {"lambda": (lambda ent: lambda v: vault.token_for(ent, v))(r.entity_type)})
            for r in results
        }
        return self.anonymizer.anonymize(text=text, analyzer_results=results,
                                         operators=operators).text

    def assert_clean(self, text: str, min_score: float = 0.85):
        """Second pass: refuse to send if a high-confidence entity survived."""
        stripped = PIIVault.TOKEN_RE.sub(" ", text)
        leaks = [r for r in self.find(stripped) if r.score >= min_score]
        if leaks:
            kinds = sorted({r.entity_type for r in leaks})
            raise ValueError(f"PII still present after masking: {kinds}")


class StreamRestorer:
    """Restores placeholders in a streamed reply. Holds back a partial '<TOKEN' tail."""

    def __init__(self, vault: PIIVault, max_token_len: int = 40):
        self.vault = vault
        self.buf = ""
        self.max_token_len = max_token_len

    def feed(self, chunk: str) -> str:
        self.buf += chunk
        cut = self.buf.rfind("<")
        if cut != -1 and ">" not in self.buf[cut:] and len(self.buf) - cut < self.max_token_len:
            ready, self.buf = self.buf[:cut], self.buf[cut:]
        else:
            ready, self.buf = self.buf, ""
        return self.vault.restore(ready)

    def flush(self) -> str:
        ready, self.buf = self.buf, ""
        return self.vault.restore(ready)

Three details came from testing, not from the docs:

Here is what the guard does to a real looking support message, using a Verhoeff valid test Aadhaar number:

IN:  Customer Priya Sharma ([email protected], +91 98765 43210) says her
     refund to UPI ID priya.s@okhdfcbank failed. Aadhaar 4987 5612 3018,
     PAN ABCPS1234K. Please draft a reply to Priya Sharma.

OUT: Customer <PERSON_1> (<EMAIL_ADDRESS_1>, <PHONE_NUMBER_1>) says her
     refund to UPI ID <UPI_ID_1> failed. Aadhaar <IN_AADHAAR_1>,
     PAN <IN_PAN_1>. Please draft a reply to <PERSON_1>.

Note that both mentions of Priya Sharma became the same <PERSON_1>, and the email address was not mistaken for a UPI ID, thanks to the lookahead in the UPI pattern.

Step 2: Call the LLM with masked text and restore the reply

The wrapper below is the only path your app should use to reach a model. It creates a fresh vault per request, masks the input, blocks the call if anything high confidence is left, logs only the masked text and restores placeholders in the answer. It uses Velona's OpenAI compatible endpoint, so the same code works with any model on the models page, and any OpenAI compatible provider if you change the base URL.

"""private_llm.py: mask, call the model, restore. Raw PII never leaves this process."""
import json
import logging
import os

from openai import OpenAI

from privacy_guard import PIIVault, PrivacyGuard, StreamRestorer

client = OpenAI(api_key=os.environ["VELONA_API_KEY"], base_url="https://velona.in/v1")
guard = PrivacyGuard()
log = logging.getLogger("llm")

SYSTEM = (
    "Some values in the user's text were replaced with placeholders such as "
    "<PERSON_1> or <PHONE_NUMBER_2>. Treat each placeholder as the real value. "
    "When you need one, copy it exactly, including the angle brackets. "
    "Never guess, invent or ask for the hidden values."
)


def private_chat(user_text: str, model: str = "deepseek/deepseek-v4-flash") -> str:
    vault = PIIVault()                      # one vault per request, kept in memory
    masked = guard.anonymize(user_text, vault)
    guard.assert_clean(masked)              # fail closed if something slipped through
    log.info("prompt=%s", masked)           # log the masked text only

    resp = client.chat.completions.create(
        model=model, temperature=0.2,
        messages=[{"role": "system", "content": SYSTEM},
                  {"role": "user", "content": masked}],
    )
    return vault.restore(resp.choices[0].message.content)


def private_chat_stream(user_text: str, model: str = "deepseek/deepseek-v4-flash"):
    vault = PIIVault()
    masked = guard.anonymize(user_text, vault)
    guard.assert_clean(masked)
    restorer = StreamRestorer(vault)
    stream = client.chat.completions.create(
        model=model, stream=True,
        messages=[{"role": "system", "content": SYSTEM},
                  {"role": "user", "content": masked}],
    )
    for chunk in stream:
        delta = chunk.choices[0].delta.content if chunk.choices else None
        if delta:
            yield restorer.feed(delta)
    yield restorer.flush()


def tool_args_for_execution(tool_call, vault: PIIVault) -> dict:
    """Model tool calls carry placeholders. Restore them only here, inside your system."""
    return json.loads(vault.restore(tool_call.function.arguments))
from private_llm import private_chat

print(private_chat(
    "Hi, I am Rohan Mehta, phone 9876501234. My refund to rohan.m@ybl is stuck. "
    "Write a short apology and say we will call back today."
))
# The model received:
#   Hi, I am <PERSON_1>, phone <PHONE_NUMBER_1>. My refund to <UPI_ID_1> is stuck. ...
# Your user sees:
#   Dear Rohan Mehta, ... we will call you on 9876501234 today.

The system prompt matters. Without it, some models "helpfully" rewrite <PERSON_1> as "the customer" or try to guess a name, and the restore step has nothing to swap back. Telling the model to copy placeholders exactly, including the angle brackets, keeps the round trip reliable. The restore regex also tolerates stray spaces such as < PERSON_1 >.

Step 3: Handle streaming replies

When you stream, a placeholder can arrive split across chunks: "<PER" in one and "SON_1>" in the next. The StreamRestorer in privacy_guard.py holds back any unfinished "<" tail of up to 40 characters until it either closes or clearly is not a placeholder, so users never see a half token. A literal "3 < 5" in the reply passes through unchanged once the stream ends.

Step 4: Keep tool calls inside your trust boundary

If your agent calls tools, the model will produce arguments such as {"customer": "<PERSON_1>"}. Restore those values with tool_args_for_execution just before running the tool on your server, and mask the tool's result again before it goes back to the model. The real value exists only inside your code. This pattern matters most for agents that look up orders or accounts, the kind of tool calling workload covered in our benchmark of cheap LLMs for production agents.

What Presidio catches, and what it misses

No detector is perfect, and a privacy layer you trust blindly is worse than one you know the limits of. We ran the guard on deliberately hard sentences. Results with en_core_web_lg:

Our test results

Hard cases, masked output and what to do

Presidio 2.2.364 with en_core_web_lg, threshold 0.5, the guard configuration in this article

InputResultFix
my name is rohan mehta and i live in asansolName masked, city "asansol" missedAdd a city list recognizer for your service area
Flat 4B, 12 Park Street, Kolkata 700016Only "Kolkata" masked. Street and PIN code leakedAdd address and PIN code recognizers with context words
Ananya Iyer from Chennai emailed usBoth masked (the small model missed both)Use en_core_web_lg or a transformer model
Voter ID ABC1234567 and passport K1234567Voter ID masked, passport missedLower the passport threshold or add a context based pattern
The patient was diagnosed with diabetesNothing masked: health data is not an entity typeUse a domain model or a keyword list for sensitive categories
Card 4111 1111 1111 1111 was chargedMasked, Luhn checksum validatedNone needed

Named entity models are trained mostly on Western news text, so Indian names in lowercase, transliterated names and small towns are weak spots. Test on samples of your own data.

For domains where these gaps matter, two open source projects are worth a look. LLM Guard from Protect AI wraps Presidio style anonymization with a vault and adds other prompt scanners. Presidio itself supports transformer based NER models, which raise recall on names at the cost of speed. And never forget the cheapest control of all: do not put data in the prompt that the task does not need.

Production checklist for privacy preserving AI

Run detection on your own serversSending raw text to a cloud PII API to protect it from a cloud LLM API defeats the purpose.

Make the guard the only doorWrap every model call, including RAG, agents and background jobs, in the same function. Block direct SDK use in code review.

Fail closedIf detection errors or the leak check fires, refuse the request instead of sending raw text.

Keep the vault short livedOne vault per request, in memory. If you must persist it for multi turn chats, encrypt it and expire it.

Log masked text onlyPrompts, traces and error reports should never contain raw PII. Check your logging library's defaults.

Mask before you indexFor RAG, mask documents before embedding so vectors and stored chunks hold no raw PII.

Keep a test set50 to 100 realistic samples from your domain, rerun after every library or model upgrade.

Review provider termsCheck retention, training and region settings for every model provider you route to.

If you are building retrieval over customer documents, combine this guard with the ingestion step in our production RAG application tutorial. For a wider view of privacy and cost trade offs, including keeping data entirely on your own hardware, see local AI versus cloud AI in India.

Do Safe defaults

  • Typed, numbered placeholders with a per request vault
  • India recognizers switched on explicitly
  • A second scan that blocks leaks
  • The large spaCy model in production
  • Restoring tool arguments only on your server
  • Logging masked prompts only
  • Testing on your own hard cases

Avoid Common mistakes

  • Assuming the default recognizers cover Aadhaar and PAN
  • Plain [REDACTED] tags that destroy meaning
  • Sending raw text to a hosted PII detector
  • Storing the vault next to the masked logs
  • Trusting detection on addresses and health data
  • Logging the raw prompt for debugging
  • Treating masking as full legal compliance

Limits of this approach

Pattern and NER based detection reduces exposure, it does not eliminate it. Context can identify a person even when every name is masked, for example "the only cardiologist in our Asansol branch". Free text descriptions, images, PDFs with scanned IDs and audio need their own handling. The DPDP Rules timeline and penalty figures are taken from the Press Information Bureau's November 2025 release and law firm summaries, so confirm details for your case. Our latency and detection results come from a small set of test sentences on one CPU, and are meant to show behaviour, not to rate Presidio's accuracy in general.

Frequently asked questions

How do I anonymize sensitive data before sending it to an LLM?

Detect personal data on your own server with a library such as Microsoft Presidio, replace each value with a consistent placeholder like <PERSON_1>, send only the masked text to the model, then replace the placeholders in the reply with the real values before showing it to the user. Keep the mapping in memory for one request and log only masked text.

Does Microsoft Presidio detect Aadhaar and PAN numbers?

Yes, but not by default. Presidio includes recognizers for Aadhaar, PAN, GSTIN, Indian passport, voter ID and vehicle registration, and they are disabled unless you add them to the analyzer registry. The Aadhaar recognizer validates the Verhoeff checksum. UPI IDs need a custom pattern.

Will masking personal data reduce the quality of LLM answers?

Usually very little, for tasks like drafting replies, summarising and classifying, because placeholders keep identity and meaning. Tell the model to copy placeholders exactly and never guess hidden values. Quality drops when the task genuinely needs the value, such as checking whether a name is spelled correctly.

Is pseudonymized data the same as anonymized data?

No. Pseudonymized data can be linked back to a person with extra information, such as the vault that maps <PERSON_1> to a name. Anonymized data cannot. Placeholder masking is pseudonymization, so the vault must stay inside your system and be deleted after use.

Does sending data to an LLM API fall under India's DPDP Act?

If the text contains digital personal data of individuals in India, processing it, including sending it to a model provider, falls under the DPDP Act, 2023 and the DPDP Rules, 2025, with most obligations in force from May 2027. Minimising the personal data you send is a practical safeguard. This is not legal advice.

How fast is PII masking with Presidio?

In our tests, masking a 134 character support message took about 11 milliseconds on a CPU with both the small and large spaCy English models, after the models were loaded. That is far below the latency of an LLM call. Longer documents scale roughly with length.

Privacy first AI, billed in rupees.
Pair this guard with 300+ models through one OpenAI compatible key. Top up with UPI from ₹10.

Get your API key

Sources and method

All code was run with Python 3.13, presidio-analyzer 2.2.364 and spaCy English models, with a stand in chat client used to verify masking, restoration, streaming and tool argument handling offline. Latency is the median of 20 runs on one cloud CPU after model loading. Detection results are from the specific sentences shown. Legal dates and penalties are quoted from the sources below.