Uncategorized

How to Do Named Entity Recognition (NER) with Python

Jul 01, 2026

Named Entity Recognition, or NER, is one of those NLP tasks that sounds fancy, but the idea is actually pretty friendly.

You give Python a piece of text, and it finds the important names and details inside it.

For example:

OpenAI opened a new office in San Francisco in May 2026.

A NER model can return:

[
  {
    "text": "OpenAI",
    "label": "ORG"
  },
  {
    "text": "San Francisco",
    "label": "GPE"
  },
  {
    "text": "May 2026",
    "label": "DATE"
  }
]

That is useful for search, analytics, document parsing, content moderation, compliance checks, CRM enrichment, fraud review, customer support, and AI workflows.

In this guide, we’ll build NER in Python in a few ways:

  1. Use spaCy for a fast local NER setup.
  2. Use Hugging Face Transformers for model-based NER.
  3. Use Stanza for multilingual and academic-style NLP.
  4. Try GLiNER for flexible custom entity labels.
  5. Add simple rules for custom business entities.
  6. Evaluate your NER results.
  7. Connect NER into a bigger AI workflow with LLMAPI.

Why We Can Write This Guide

We’ve spent around 6 years working with AI APIs, NLP tools, document parsing, text automation, and LLM workflows. We also researched current Python NER tools and newer NER research for this article, including spaCy, Hugging Face Transformers, Stanza, GLiNER, and LLM-based entity extraction.

The practical lesson is simple: there is no single “best” NER method for every project. spaCy is great for fast local extraction. Hugging Face is great when you want transformer-based models. GLiNER is interesting when you want custom labels without training a whole model. LLMs are useful for flexible extraction, but they need validation because they can return entities that are not actually in the text.

What Does NER Detect?

NER models usually detect entities like:

Entity typeExample
PersonSam Altman
OrganizationOpenAI
LocationChicago
Country/city/stateUkraine, California
DateJuly 22, 2026
Money$15 million
ProductiPhone
EventWWDC
Law or documentGDPR
Medical termibuprofen, diabetes
Custom entityinvoice_id, claim_number, policy_id

Different libraries use different labels. For example, spaCy often uses PERSON, ORG, GPE, DATE, and MONEY. Hugging Face models may use labels like PER, ORG, LOC, and MISC.

So before writing code, decide what you actually need to find. Names and companies? Dates and money? Product SKUs? Invoice numbers? Medical conditions? The answer changes the tool choice.

Option 1: Use spaCy for Quick Local NER

spaCy is one of the easiest ways to do NER in Python. Its EntityRecognizer docs describe the ner component as a trainable pipeline component for named entity recognition. In normal use, you load a language pipeline, pass text into it, and read entities from doc.ents.

Install spaCy:

pip install spacy
python -m spacy download en_core_web_sm

Now create a small NER script:

import spacy

nlp = spacy.load("en_core_web_sm")

text = "Apple hired John Smith in London for $120,000 in 2026."

doc = nlp(text)

for entity in doc.ents:
    print(entity.text, entity.label_)

Output:

Apple ORG
John Smith PERSON
London GPE
$120,000 MONEY
2026 DATE

That is your first NER pipeline.

When spaCy Is a Good Fit

spaCy is a good choice when you want something fast, local, and easy to use.

NeedspaCy fit
Local Python NERStrong
Fast prototypesStrong
Standard entitiesStrong
Production appsStrong with testing
Custom model trainingSupported
Zero-shot custom labelsLess flexible than GLiNER or LLMs
Deep entity linkingNeeds extra tools

Use spaCy when you need a clean first version. It is also nice because it returns character offsets, which means you can highlight the exact entity inside the original text.

for entity in doc.ents:
    print({
        "text": entity.text,
        "label": entity.label_,
        "start": entity.start_char,
        "end": entity.end_char
    })

Example:

{
  "text": "Apple",
  "label": "ORG",
  "start": 0,
  "end": 5
}

Offsets are very useful for redaction, highlighting, annotations, and review tools.

Option 2: Use Hugging Face Transformers for NER

Hugging Face Transformers is a good choice when you want transformer-based NER models.

The Hugging Face pipeline docs explain that pipelines provide a simple API for tasks including Named Entity Recognition, while the token classification docs describe NER as one of the most common token classification tasks.

Install:

pip install transformers torch

Use the NER pipeline:

from transformers import pipeline

ner = pipeline(
    "ner",
    model="dslim/bert-base-NER",
    aggregation_strategy="simple"
)

text = "Hugging Face is based in New York City."

results = ner(text)

for entity in results:
    print(entity)

Example output:

{
  "entity_group": "ORG",
  "score": 0.99,
  "word": "Hugging Face",
  "start": 0,
  "end": 12
}
{
  "entity_group": "LOC",
  "score": 0.99,
  "word": "New York City",
  "start": 25,
  "end": 38
}

The aggregation_strategy=”simple” part matters because transformer tokenizers often split words into subword pieces. Aggregation groups those pieces back into cleaner entities.

When Hugging Face Is a Good Fit

NeedHugging Face fit
Transformer-based NERStrong
Many model choicesStrong
Multilingual modelsStrong if you choose the right model
Fine-tuningStrong
Local or hosted model useFlexible
Very simple setupSlightly heavier than spaCy
Custom entity labels without trainingUse GLiNER or LLMs instead

Hugging Face is especially useful if you want to try different models. You can search the Hugging Face Hub for NER models trained on biomedical text, legal text, multilingual text, or specific datasets.

Option 3: Use Stanza for Academic-Style NLP

Stanza is a Python NLP library from Stanford. Its NER docs explain that NER is handled by the NERProcessor, which you can invoke with the processor name ner.

Install:

pip install stanza

Download English models:

import stanza

stanza.download("en")

Run NER:

import stanza

nlp = stanza.Pipeline(lang="en", processors="tokenize,ner")

doc = nlp("Barack Obama was born in Hawaii.")

for sentence in doc.sentences:
    for entity in sentence.ents:
        print(entity.text, entity.type)

Output:

Barack Obama PERSON

Hawaii GPE

When Stanza Is a Good Fit

Use Stanza if you want a solid academic NLP toolkit with tokenization, POS tagging, lemmatization, dependency parsing, and NER in one pipeline.

NeedStanza fit
Research-style NLPStrong
Multilingual pipelinesStrong
NER plus parsingStrong
Simple app integrationGood, but spaCy may feel easier
Web-scale performanceNeeds testing
Custom zero-shot labelsLess ideal

Stanza is a good choice when you need more than entities. For example, if you want entities plus grammar structure or dependency parsing, it can be useful.

Option 4: Use GLiNER for Custom Labels

Classic NER models usually detect fixed labels. That is fine if you only need PERSON, ORG, LOC, and DATE.

But what if you need labels like:

Custom labelExample
invoice_numberINV-2026-1049
policy_idPOL-88321
competitorSalesforce
medical_testTSH panel
product_featuredark mode
shipping_issuelate delivery

This is where GLiNER is interesting.

The GLiNER paper introduced a generalist lightweight NER model that can identify arbitrary entity types. The authors explain that traditional NER models are limited to predefined entity types, while LLMs can extract custom entities but are often larger and more expensive. GLiNER is designed as a smaller model that can handle flexible labels.

Install:

pip install gliner

Use it:

from gliner import GLiNER

model = GLiNER.from_pretrained("urchade/gliner_medium-v2.1")

text = """
Invoice INV-2026-1049 from Northside Office Supply
is due on August 15, 2026 for $1,240.
"""

labels = [
    "invoice number",
    "vendor",
    "due date",
    "money amount"
]

entities = model.predict_entities(text, labels)

for entity in entities:
    print(entity["text"], "=>", entity["label"])

Example output:

INV-2026-1049 => invoice number
Northside Office Supply => vendor
August 15, 2026 => due date
$1,240 => money amount

When GLiNER Is a Good Fit

GLiNER is a good middle ground when you want custom labels without building a full training dataset.

NeedGLiNER fit
Custom labelsStrong
Zero-shot entity extractionStrong
Local modelStrong
Lower cost than LLM callsStrong
Standard entitiesGood
Complex reasoningUse LLMs
Strict production accuracyTest and validate

The research point here is important. In the GLiNER arXiv paper, the authors argue that LLMs are flexible but expensive and slower for entity extraction, while GLiNER uses a bidirectional transformer encoder to extract entities in parallel. That fits a real product problem: if your app extracts entities from thousands of messages, cost and speed matter.

Option 5: Add Regex for Business IDs

NER models are good for names and natural language entities. Regex is still better for fixed patterns.

For example:

EntityPattern
Invoice numberINV-2026-1049
Ticket IDTICKET-9931
Order IDORD-20482
Policy numberPOL-88321
SSN123-45-6789
Email[email protected]

Use regex for these.

import re

text = """
Customer Sarah Lee submitted ticket TICKET-9931.
Invoice INV-2026-1049 was attached.
Email: [email protected]
"""

patterns = {
    "ticket_id": r"\bTICKET-\d+\b",
    "invoice_number": r"\bINV-\d{4}-\d+\b",
    "email": r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b"
}

for label, pattern in patterns.items():
    for match in re.finditer(pattern, text, flags=re.IGNORECASE):
        print({
            "text": match.group(),
            "label": label,
            "start": match.start(),
            "end": match.end()
        })

Output:

{'text': 'TICKET-9931', 'label': 'ticket_id', 'start': 36, 'end': 47}
{'text': 'INV-2026-1049', 'label': 'invoice_number', 'start': 57, 'end': 70}
{'text': '[email protected]', 'label': 'email', 'start': 93, 'end': 114}

This is the practical rule: use models for messy language, and use regex for stable patterns.

Option 6: Combine spaCy and Regex

A real app often needs both.

Example:

import re
import spacy

nlp = spacy.load("en_core_web_sm")

regex_patterns = {
    "invoice_number": r"\bINV-\d{4}-\d+\b",
    "email": r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b"
}

def extract_entities(text):
    entities = []

    doc = nlp(text)

    for ent in doc.ents:
        entities.append({
            "text": ent.text,
            "label": ent.label_,
            "start": ent.start_char,
            "end": ent.end_char,
            "source": "spacy"
        })

    for label, pattern in regex_patterns.items():
        for match in re.finditer(pattern, text, flags=re.IGNORECASE):
            entities.append({
                "text": match.group(),
                "label": label,
                "start": match.start(),
                "end": match.end(),
                "source": "regex"
            })

    return entities

text = """
Sarah Lee from Acme Inc. sent invoice INV-2026-1049
to [email protected] on July 20, 2026.
"""

for entity in extract_entities(text):
    print(entity)

Example output:

{'text': 'Sarah Lee', 'label': 'PERSON', 'start': 1, 'end': 10, 'source': 'spacy'}
{'text': 'Acme Inc.', 'label': 'ORG', 'start': 16, 'end': 25, 'source': 'spacy'}
{'text': 'July 20, 2026', 'label': 'DATE', 'start': 86, 'end': 99, 'source': 'spacy'}
{'text': 'INV-2026-1049', 'label': 'invoice_number', 'start': 39, 'end': 52, 'source': 'regex'}
{'text': '[email protected]', 'label': 'email', 'start': 56, 'end': 75, 'source': 'regex'}

This is often the best first production pattern.

How Do You Return Clean JSON?

If you are building an API, make the output predictable.

def ner_to_json(text):
    entities = extract_entities(text)

    return {
        "text": text,
        "entity_count": len(entities),
        "entities": entities
    }

Example result:

{
  "text": "Sarah Lee from Acme Inc. sent invoice INV-2026-1049.",
  "entity_count": 3,
  "entities": [
    {
      "text": "Sarah Lee",
      "label": "PERSON",
      "start": 0,
      "end": 9,
      "source": "spacy"
    },
    {
      "text": "Acme Inc.",
      "label": "ORG",
      "start": 15,
      "end": 24,
      "source": "spacy"
    },
    {
      "text": "INV-2026-1049",
      "label": "invoice_number",
      "start": 38,
      "end": 51,
      "source": "regex"
    }
  ]
}

This kind of output is easy to store, inspect, and send to a frontend.

Build a Small NER API with FastAPI

Now let’s wrap the NER pipeline in an API.

Install:

pip install fastapi uvicorn spacy

python -m spacy download en_core_web_sm

Create app.py:

import re
import spacy
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()
nlp = spacy.load("en_core_web_sm")

regex_patterns = {
    "invoice_number": r"\bINV-\d{4}-\d+\b",
    "email": r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b"
}

class TextRequest(BaseModel):
    text: str

def extract_entities(text):
    entities = []
    doc = nlp(text)

    for ent in doc.ents:
        entities.append({
            "text": ent.text,
            "label": ent.label_,
            "start": ent.start_char,
            "end": ent.end_char,
            "source": "spacy"
        })

    for label, pattern in regex_patterns.items():
        for match in re.finditer(pattern, text, flags=re.IGNORECASE):
            entities.append({
                "text": match.group(),
                "label": label,
                "start": match.start(),
                "end": match.end(),
                "source": "regex"
            })

    return entities

@app.post("/ner")
def run_ner(request: TextRequest):
    entities = extract_entities(request.text)

    return {
        "entity_count": len(entities),
        "entities": entities
    }

Run it:

uvicorn app:app –reload

Test it:

curl -X POST "http://127.0.0.1:8000/ner" \
  -H "Content-Type: application/json" \
  -d '{"text":"Sarah Lee from Acme Inc. sent invoice INV-2026-1049 to [email protected]."}'

Example response:

{
  "entity_count": 4,
  "entities": [
    {
      "text": "Sarah Lee",
      "label": "PERSON",
      "start": 0,
      "end": 9,
      "source": "spacy"
    },
    {
      "text": "Acme Inc.",
      "label": "ORG",
      "start": 15,
      "end": 24,
      "source": "spacy"
    },
    {
      "text": "INV-2026-1049",
      "label": "invoice_number",
      "start": 38,
      "end": 51,
      "source": "regex"
    },
    {
      "text": "[email protected]",
      "label": "email",
      "start": 55,
      "end": 74,
      "source": "regex"
    }
  ]
}

Now you have a small NER service.

How Do You Evaluate NER?

NER quality should be tested with your own text.

Use metrics like:

MetricMeaning
PrecisionOf the entities the model found, how many were correct?
RecallOf the real entities in the text, how many did the model find?
F1 scoreBalance between precision and recall
Label accuracyDid the model choose the right entity type?
Offset accuracyDid it mark the right character span?
Review rateHow often would humans need to fix it?

Example:

Text: Sarah Lee works at Acme Inc.

Gold entities:
Sarah Lee = PERSON
Acme Inc. = ORG

Model entities:
Sarah Lee = PERSON
Acme = ORG

The model got the entity type right, but the span for Acme Inc. is incomplete. That matters if you use NER for highlighting, redaction, or structured extraction.

NER evaluation research usually treats this as a sequence-labeling problem, where both the span and label matter. That is also why LLM-based NER needs special care. The GPT-NER paper explains that classic NER is sequence labeling, while LLM-based NER is generation. The paper also discusses hallucination risk, where the model may label something as an entity even when no entity is present.

For production, build a small test set:

Text typeNumber of examples
Customer messages50
Documents50
Emails50
Edge cases50

Manually mark the correct entities. Then compare spaCy, Hugging Face, GLiNER, regex, or an API.

What About LLMs for NER?

LLMs can extract entities with prompts.

Example:

Extract these entity types from the text:
person, company, invoice number, due date, total amount.

Return valid JSON only.

Text:
Sarah Lee from Acme Inc. sent invoice INV-2026-1049,
due August 15, 2026, for $1,240.

Expected output:

{
  "person": ["Sarah Lee"],
  "company": ["Acme Inc."],
  "invoice_number": ["INV-2026-1049"],
  "due_date": ["August 15, 2026"],
  "total_amount": ["$1,240"]
}

LLMs are useful when your labels are weird, changing, or hard to train.

Use them when:

NeedLLM fit
Custom labelsStrong
Few examplesStrong
Flexible extractionStrong
Natural-language instructionsStrong
Strict offsetsWeaker
High-volume low-cost extractionDepends on model
Zero hallucination toleranceNeeds validation

Research supports this tradeoff. GPT-NER showed that LLM-style NER can work well in low-resource and few-shot setups, but it also needed self-verification to reduce hallucinated entities. That fits real app design: if you use LLMs for NER, ask them to verify extracted entities and validate the output against the original text.

A safer LLM NER workflow:

  1. Ask the model to extract entities.
  2. Ask it to verify that every entity appears in the original text.
  3. Validate JSON schema.
  4. Check that extracted spans or strings exist in the source.
  5. Send uncertain results to review.

Where LLMAPI Fits

LLMAPI fits when NER is part of a bigger AI workflow.

For example, your app may need to:

  1. Extract people, companies, dates, and IDs from text.
  2. Redact sensitive data.
  3. Classify the message.
  4. Summarize the issue.
  5. Route the result to a team.
  6. Generate a response draft.
  7. Track model usage and fallback.

LLMAPI can help route different steps to different models.

TaskSuggested approach
Standard NERspaCy, Hugging Face, Stanza, or cloud NER API
Custom entity extractionGLiNER or LLM through LLMAPI
RedactionRegex + NER + validation
Summary after extractionLLMAPI route to summarization model
High-risk review notesStronger reasoning model
Batch taggingCheaper model route
FallbackBackup model/provider through LLMAPI

The clean workflow is simple: Python extracts the entities, then LLMAPI helps decide what happens next.

Common Mistakes

MistakeBetter approach
Using only one perfect demo sentenceTest real messy text
Ignoring character offsetsStore start and end positions
Using LLMs without validationCheck extracted entities against source text
Expecting spaCy to find custom IDsUse regex or GLiNER
Treating all labels as equalDefine labels clearly
Skipping multilingual testingTest every language your users write in
No review pathAdd review for low-confidence or sensitive results
No evaluation setManually label examples and compare tools
Confusing NER with entity linkingLinking needs extra tools
Forgetting privacyNER often touches names, emails, IDs, and addresses

Which Python NER Option Should You Choose?

Here is the practical version.

NeedBest first choice
Fast local NERspaCy
Transformer model NERHugging Face Transformers
NLP pipeline with parsingStanza
Custom labels without trainingGLiNER
Fixed IDs and codesRegex
Business workflow APIFastAPI + spaCy/GLiNER
Flexible custom extractionLLM through LLMAPI
High-volume productionTest spaCy, GLiNER, and Hugging Face on your own data

If you are building your first NER feature, start with spaCy plus regex. That gives you standard entities and custom business patterns quickly.

If you need custom entity labels, test GLiNER.

If you need many model choices or fine-tuning, use Hugging Face.

If you need flexible extraction with natural-language labels, use an LLM, but add validation.

Final Thoughts

You can do Named Entity Recognition in Python with just a few lines of code.

Use spaCy if you want a fast and simple local setup. Use Hugging Face Transformers if you want model flexibility and transformer-based NER. Use Stanza if you want a fuller NLP pipeline. Use GLiNER if you need custom entity labels without training a new model. Use regex for fixed patterns like invoice numbers, emails, order IDs, and policy numbers.

For production, do not stop at “it found something.” Store offsets, labels, confidence scores where available, and source information. Test with real text. Add validation for custom IDs. Add review for sensitive results.

And if NER is only one step in a larger AI workflow, connect it with LLMAPI so you can route follow-up tasks like redaction, classification, summarization, compliance checks, and response generation across different models.

Deploy in minutes