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:
- Use spaCy for a fast local NER setup.
- Use Hugging Face Transformers for model-based NER.
- Use Stanza for multilingual and academic-style NLP.
- Try GLiNER for flexible custom entity labels.
- Add simple rules for custom business entities.
- Evaluate your NER results.
- 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 type | Example |
| Person | Sam Altman |
| Organization | OpenAI |
| Location | Chicago |
| Country/city/state | Ukraine, California |
| Date | July 22, 2026 |
| Money | $15 million |
| Product | iPhone |
| Event | WWDC |
| Law or document | GDPR |
| Medical term | ibuprofen, diabetes |
| Custom entity | invoice_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.
| Need | spaCy fit |
| Local Python NER | Strong |
| Fast prototypes | Strong |
| Standard entities | Strong |
| Production apps | Strong with testing |
| Custom model training | Supported |
| Zero-shot custom labels | Less flexible than GLiNER or LLMs |
| Deep entity linking | Needs 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
| Need | Hugging Face fit |
| Transformer-based NER | Strong |
| Many model choices | Strong |
| Multilingual models | Strong if you choose the right model |
| Fine-tuning | Strong |
| Local or hosted model use | Flexible |
| Very simple setup | Slightly heavier than spaCy |
| Custom entity labels without training | Use 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.
| Need | Stanza fit |
| Research-style NLP | Strong |
| Multilingual pipelines | Strong |
| NER plus parsing | Strong |
| Simple app integration | Good, but spaCy may feel easier |
| Web-scale performance | Needs testing |
| Custom zero-shot labels | Less 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 label | Example |
| invoice_number | INV-2026-1049 |
| policy_id | POL-88321 |
| competitor | Salesforce |
| medical_test | TSH panel |
| product_feature | dark mode |
| shipping_issue | late 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.
| Need | GLiNER fit |
| Custom labels | Strong |
| Zero-shot entity extraction | Strong |
| Local model | Strong |
| Lower cost than LLM calls | Strong |
| Standard entities | Good |
| Complex reasoning | Use LLMs |
| Strict production accuracy | Test 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:
| Entity | Pattern |
| Invoice number | INV-2026-1049 |
| Ticket ID | TICKET-9931 |
| Order ID | ORD-20482 |
| Policy number | POL-88321 |
| SSN | 123-45-6789 |
| [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:
| Metric | Meaning |
| Precision | Of the entities the model found, how many were correct? |
| Recall | Of the real entities in the text, how many did the model find? |
| F1 score | Balance between precision and recall |
| Label accuracy | Did the model choose the right entity type? |
| Offset accuracy | Did it mark the right character span? |
| Review rate | How 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 type | Number of examples |
| Customer messages | 50 |
| Documents | 50 |
| Emails | 50 |
| Edge cases | 50 |
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:
| Need | LLM fit |
| Custom labels | Strong |
| Few examples | Strong |
| Flexible extraction | Strong |
| Natural-language instructions | Strong |
| Strict offsets | Weaker |
| High-volume low-cost extraction | Depends on model |
| Zero hallucination tolerance | Needs 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:
- Ask the model to extract entities.
- Ask it to verify that every entity appears in the original text.
- Validate JSON schema.
- Check that extracted spans or strings exist in the source.
- 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:
- Extract people, companies, dates, and IDs from text.
- Redact sensitive data.
- Classify the message.
- Summarize the issue.
- Route the result to a team.
- Generate a response draft.
- Track model usage and fallback.
LLMAPI can help route different steps to different models.
| Task | Suggested approach |
| Standard NER | spaCy, Hugging Face, Stanza, or cloud NER API |
| Custom entity extraction | GLiNER or LLM through LLMAPI |
| Redaction | Regex + NER + validation |
| Summary after extraction | LLMAPI route to summarization model |
| High-risk review notes | Stronger reasoning model |
| Batch tagging | Cheaper model route |
| Fallback | Backup model/provider through LLMAPI |
The clean workflow is simple: Python extracts the entities, then LLMAPI helps decide what happens next.
Common Mistakes
| Mistake | Better approach |
| Using only one perfect demo sentence | Test real messy text |
| Ignoring character offsets | Store start and end positions |
| Using LLMs without validation | Check extracted entities against source text |
| Expecting spaCy to find custom IDs | Use regex or GLiNER |
| Treating all labels as equal | Define labels clearly |
| Skipping multilingual testing | Test every language your users write in |
| No review path | Add review for low-confidence or sensitive results |
| No evaluation set | Manually label examples and compare tools |
| Confusing NER with entity linking | Linking needs extra tools |
| Forgetting privacy | NER often touches names, emails, IDs, and addresses |
Which Python NER Option Should You Choose?
Here is the practical version.
| Need | Best first choice |
| Fast local NER | spaCy |
| Transformer model NER | Hugging Face Transformers |
| NLP pipeline with parsing | Stanza |
| Custom labels without training | GLiNER |
| Fixed IDs and codes | Regex |
| Business workflow API | FastAPI + spaCy/GLiNER |
| Flexible custom extraction | LLM through LLMAPI |
| High-volume production | Test 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.