Wykrywanie danych wrażliwych w tekście: regex, PESEL i NIP w Pythonie

Piotr Dul

28-10-2026

(aktualizacja: 28-10-2026)

Jak znaleźć w tekście e-maile, telefony, PESEL i NIP wyrażeniami regularnymi i sumami kontrolnymi, żeby sam regex nie zgłaszał fałszywych trafień.

Dane o stałej strukturze, takie jak e-mail, telefon, PESEL i NIP, wykrywasz w Pythonie wyrażeniami regularnymi z modułu re, a PESEL i NIP dodatkowo sprawdzasz sumą kontrolną, bo sam regex zgłasza każdy ciąg o pasującej długości. Każde trafienie zapisz razem z jego pozycją w tekście (początek i koniec), żeby można je było potem bezpiecznie podmienić. Imion i nazwisk wyrażenia regularne nie wykryją.

Zanim ukryjesz w tekście dane wrażliwe, musisz je znaleźć. To pozornie proste: napisz wyrażenie regularne na PESEL i gotowe. W praktyce pierwsza wersja łapie numery zamówień, kody paczek i identyfikatory, a przy tym przeoczy numery zapisane z myślnikami. W tej części zbudujemy detektor, który robi to rzetelniej, i przygotujemy jego wynik tak, żeby kolejne części cyklu mogły go użyć.

Ten tekst jest częścią cyklu o pseudonimizacji danych w Pythonie. Same wyrażenia regularne i sumy kontrolne wystarczą tu bez żadnych dodatkowych bibliotek, a gotowy detektor wpiszemy później jako węzeł grafu w LangGraph. Jeśli nie wiesz jeszcze, czym jest graf w programowaniu, możesz zacząć od tamtego tekstu. Tutaj używamy tylko biblioteki standardowej Pythona, czyli modułów re i dataclasses.

Dlaczego samo wyrażenie regularne nie wystarcza do wykrywania PESEL?

PESEL to 11 cyfr. Wyrażenie \d{11} znajdzie każdy ciąg 11 cyfr: prawdziwy PESEL, ale też numer zamówienia, numer telefonu z kodem kraju zapisany bez spacji czy identyfikator z systemu księgowego. Regex potrafi sprawdzić kształt tekstu, ale nie to, czy ciąg jest numerem, który ma sens.

Dlatego dla PESEL i NIP robimy dwa kroki:

KrokNarzędzieCo sprawdza
1. KształtWyrażenie regularneCzy ciąg ma właściwą liczbę cyfr i separatorów
2. SensSuma kontrolnaCzy ostatnia cyfra zgadza się z pozostałymi

Dla e-maila i telefonu zostajemy przy kroku pierwszym, bo nie mają sumy kontrolnej.

Jak napisać wyrażenia regularne dla e-maila, telefonu, PESEL i NIP?

Wzorce kompilujemy raz, na poziomie modułu, jako stałe. Zapis r"..." to surowy ciąg znaków: Python nie interpretuje w nim ukośników, więc \d trafia do re dokładnie takie, jakie napisałeś:

import re

EMAIL = re.compile(r"[\w.+-]+@[\w-]+(?:\.[\w-]+)+")
PHONE = re.compile(r"(?<![\w-])(?:\+48[ -]?)?\d{3}[ -]?\d{3}[ -]?\d{3}(?![\w-])")
PESEL = re.compile(r"(?<!\d)\d{11}(?!\d)")
NIP = re.compile(r"(?<![\d-])\d{3}-?\d{3}-?\d{2}-?\d{2}(?![\d-])")

Co tu się dzieje:

  • \w w Pythonie 3 dopasowuje litery Unicode, więc żółć@example.pl zostanie znalezione bez dodatkowych ustawień. Na pytanie o polskie znaki w regexach odpowiedź jest więc prosta: dla zwykłych ciągów str działają od razu.
  • \d{3}[ -]?\d{3}[ -]?\d{3} to telefon: trzy grupy po trzy cyfry, rozdzielone opcjonalną spacją lub myślnikiem, z opcjonalnym prefiksem +48.
  • (?<!\d) i (?!\d) to lookbehind i lookahead (asercje wyprzedzające). Mówią: „przed tym nie może stać cyfra” i „po tym nie może stać cyfra”. Bez nich \d{11} znalazłoby 11 cyfr w środku dłuższego numeru.
  • W telefonie i NIP dodaliśmy do asercji także myślnik, żeby numer NIP 987-654-32-10 nie został pocięty na kawałki i uznany za telefon.

Jak sprawdzić sumę kontrolną PESEL i NIP?

Ostatnia cyfra PESEL to cyfra kontrolna. Liczymy ją, mnożąc pierwsze dziesięć cyfr przez wagi 1, 3, 7, 9, 1, 3, 7, 9, 1, 3, sumując wyniki i biorąc dopełnienie do dziesięciu. NIP działa podobnie, tylko z wagami 6, 5, 7, 2, 3, 4, 5, 6, 7 i dzieleniem przez 11:

def pesel_is_valid(pesel: str) -> bool:
    weights = (1, 3, 7, 9, 1, 3, 7, 9, 1, 3)
    total = sum(int(d) * w for d, w in zip(pesel, weights))
    return (10 - total % 10) % 10 == int(pesel[10])


def nip_is_valid(nip: str) -> bool:
    digits = nip.replace("-", "")
    weights = (6, 5, 7, 2, 3, 4, 5, 6, 7)
    check = sum(int(d) * w for d, w in zip(digits, weights)) % 11
    return check != 10 and check == int(digits[9])

W nip_is_valid jest warunek check != 10: gdy reszta z dzielenia wynosi 10, taki NIP nie może istnieć, więc odrzucamy go, zamiast porównywać z cyfrą.

Jak zebrać wszystkie trafienia razem z ich pozycjami?

W następnej części będziemy podmieniać znalezione dane na tokeny, więc samo "44051401359" nie wystarczy. Potrzebujemy wiedzieć, gdzie w tekście to jest. Zapisujemy to w małej niemutowalnej klasie:

from dataclasses import dataclass


@dataclass(frozen=True)
class Finding:
    kind: str
    value: str
    start: int
    end: int


def find_sensitive(text: str) -> list[Finding]:
    findings = []
    for match in EMAIL.finditer(text):
        findings.append(Finding("email", match.group(), match.start(), match.end()))
    for match in PESEL.finditer(text):
        if pesel_is_valid(match.group()):
            findings.append(Finding("pesel", match.group(), match.start(), match.end()))
    for match in NIP.finditer(text):
        if nip_is_valid(match.group()):
            findings.append(Finding("nip", match.group(), match.start(), match.end()))
    for match in PHONE.finditer(text):
        findings.append(Finding("phone", match.group(), match.start(), match.end()))
    return sorted(findings, key=lambda f: f.start)

finditer zamiast findall daje obiekty dopasowania, z których bierzemy start() i end(). frozen=True sprawia, że trafienia nie da się przypadkiem zmienić, a klasa jest jeszcze gotowa do użycia jako klucz w słowniku. Na końcu sortujemy po pozycji, żeby wynik czytało się od początku tekstu.

Jak to działa na prawdziwym tekście?

Przetestowałem funkcję na takim tekście: dwa poprawne numery, e-mail, telefon, numer zamówienia z 11 cyframi i PESEL z błędną cyfrą kontrolną:

Klient Jan Kowalski (PESEL 44051401359, NIP 987-654-32-10) prosi o kontakt:
jan.kowalski@example.com albo +48 600 123 456. Zamówienie nr 12345678901, błędny PESEL 44051401358.

Wynik find_sensitive(text):

Finding(kind='pesel', value='44051401359', start=27, end=38)
Finding(kind='nip', value='987-654-32-10', start=44, end=57)
Finding(kind='email', value='jan.kowalski@example.com', start=76, end=100)
Finding(kind='phone', value='+48 600 123 456', start=106, end=121)

Sam regex PESEL.findall(text) zwróciłby trzy ciągi: poprawny PESEL, numer zamówienia i PESEL z błędną cyfrą. Po dodaniu sumy kontrolnej zostaje tylko pierwszy. PESEL 44051401359 to przykładowy numer znany z dokumentacji, a NIP 987-654-32-10 ma poprawną sumę kontrolną i został dobrany tylko do przykładu.

Czego ten detektor nie wykryje i gdzie się myli?

Ważne, żebyś znał granice tego rozwiązania, zanim użyjesz go na prawdziwych dokumentach:

ProblemPrzykładDlaczego
Suma kontrolna to nie dowódLosowy 11-cyfrowy ciągCo dziesiąty losowy ciąg ma poprawną cyfrę kontrolną PESEL
Telefon bez sumy kontrolnejNumer faktury 600123456Dziewięć cyfr to też „telefon” według wzorca
Imiona i nazwiskaJan KowalskiRegex nie odróżni nazwiska od zwykłych dwóch słów
Dane w innym zapisiePESEL ze spacjami po grupachWzorzec zakłada zapis ciągły

Pierwszy wiersz sprawdziłem na 100 000 losowych 11-cyfrowych ciągach: 9,87% przeszło sumę kontrolną PESEL. To wynika wprost z konstrukcji, bo ostatnia cyfra jest wyznaczona przez poprzednie, więc trafia się w jednym przypadku na dziesięć. Suma kontrolna wyrzuca zatem większość przypadkowych numerów, ale nie wszystkie. Dlatego detektor to dopiero propozycja, a nie ostateczna decyzja, i w następnych częściach cyklu dodamy krok akceptacji przez operatora.

Pamiętaj też, że wykrycie i usunięcie danych w tekście nie czyni Cię zgodnym z RODO.

Szerszy kontekst prawny (AI Act, prawa autorskie, RODO i odpowiedzialność za szkody wyrządzone przez sztuczną inteligencję w pracy) omawia w naszym podcaście prawniczka Paula Skrzypecka:

Jak przetestować detektor w pytest?

Każdy typ danych dostaje test „znajduje poprawny” i test „ignoruje niepoprawny”. Plus test, że pozycje wskazują dokładnie wartość:

import pytest
from detectors import find_sensitive, nip_is_valid, pesel_is_valid


def kinds(text):
    return [(f.kind, f.value) for f in find_sensitive(text)]


def test_valid_pesel_is_found():
    assert kinds("PESEL: 44051401359.") == [("pesel", "44051401359")]


def test_pesel_with_wrong_checksum_is_ignored():
    assert kinds("Numer zamówienia 44051401358") == []


def test_eleven_digit_order_number_is_not_a_pesel():
    assert kinds("Zamówienie 12345678901") == []


def test_valid_nip_with_dashes_is_found():
    assert kinds("NIP 987-654-32-10") == [("nip", "987-654-32-10")]


def test_nip_with_wrong_checksum_is_ignored():
    assert kinds("NIP 987-654-32-11") == []


def test_email_with_polish_letters():
    assert kinds("żółć@example.pl") == [("email", "żółć@example.pl")]


def test_phone_with_prefix_and_spaces():
    assert kinds("+48 600 123 456") == [("phone", "+48 600 123 456")]


def test_positions_point_at_the_value():
    text = "Mail: jan@example.com"
    finding = find_sensitive(text)[0]
    assert text[finding.start:finding.end] == finding.value


def test_findings_are_sorted_by_position():
    result = kinds("600123456 a potem jan@example.com")
    assert [k for k, _ in result] == ["phone", "email"]


@pytest.mark.parametrize("number,expected", [("44051401359", True), ("44051401350", False)])
def test_pesel_checksum(number, expected):
    assert pesel_is_valid(number) is expected


def test_nip_checksum_rejects_remainder_ten():
    assert nip_is_valid("1234567890") is False

Ostatni test pilnuje przypadku brzegowego z check != 10. Szerszy kontekst pisania testów w pytest znajdziesz w artykule o testowaniu kodu z API LLM.

Czy wyrażenia regularne do formularza i do wykrywania w tekście to to samo?

Nie, choć używają tego samego modułu. Walidacja formularza sprawdza, czy całe pole ma poprawny kształt, więc wzorzec zaczyna się od ^ i kończy na $. Wykrywanie w tekście szuka fragmentów w dowolnym miejscu, dlatego potrzebuje asercji (?<!\d) i (?!\d) zamiast kotwic. Jeśli chcesz poznać wersję dla pól formularza, zobacz walidację formularza w JavaScript.

Od czego zacząć pisanie testów detektora danych?

Zacznij od testów dla tego, czego detektor nie powinien łapać: numeru zamówienia, numeru faktury, kodu pocztowego. Test, że PESEL zostaje znaleziony, napisze każdy i zwykle przejdzie od razu. Fałszywe trafienia są groźniejsze, bo psują zaufanie do narzędzia: operator, który kilka razy zobaczy zaznaczony numer faktury, przestaje wierzyć pozostałym trafieniom.

Co dalej?

Mamy listę trafień z pozycjami. W kolejnej części zamienimy je na tokeny typu [PESEL_1] i zapiszemy tabelę mapowania, czyli pseudonimizację właściwą. Całą ścieżkę Pythona znajdziesz na stronie ścieżki rozwoju w Pythonie.

Konsultacja i warsztaty devmentor.pl

Masz pytania?

Partnerzy

Firmy, z którymi współpracujemy

To partnerzy odpowiedzialni za staże po naszych kursach. Organizują je dla najlepszych absolwentów.

Zobacz wszystkich partnerów →
  • VR Medical
  • Sophona
  • Robot Force