---
title: "How Do Spam Filters Work? Build a Simple One in Python"
description: "How spam filters work: build a Naive Bayes filter in Python, see what it learns, the mistake it makes, and the false alarm trade-off."
slug: how-do-spam-filters-work
canonical: https://learn.modernagecoders.com/blog/how-do-spam-filters-work/
date: 2026-09-28
dateModified: 2026-09-28
category: "Artificial Intelligence"
tags: ["Machine Learning", "Python", "AI Literacy", "Probability"]
keywords: ["how do spam filters work", "naive bayes spam filter python", "how does email spam detection work", "machine learning spam filter", "bayesian spam filtering", "laplace smoothing", "false positive spam"]
readTime: "8 min read"
author: "Modern Age Coders Team"
---
# How Do Spam Filters Work? Build a Simple One in Python

> Train a Naive Bayes filter from scratch, look inside at what it learned, and see the mistake it makes and the trade-off every filter has to choose.

![How spam filters work: an envelope with a spam probability meter reading 97 percent](/images/blog/how-do-spam-filters-work/00-hero.png)

*By Modern Age Coders Team · 2026-09-28 · 8 min read*

**Quick answer:** Spam filters learn from labelled examples which words are common in spam and which in normal messages, then combine the evidence from every word in a new message into a spam probability. The classic method, Naive Bayes, needs only word counts and fits in about forty lines of Python. Add-one smoothing stops a single unseen word from wiping out all the evidence. Filters make mistakes when normal messages share words with spam, and choosing the threshold trades false alarms against missed spam.

Every day, spam filters quietly move enormous numbers of junk messages out of inboxes without anyone noticing. They are one of the oldest and most successful uses of machine learning, and the classic method behind them is simple enough to build from scratch in about forty lines of Python, using nothing but word counts and a little probability.

This guide does exactly that. We train a small filter on example messages, test it on messages it has never seen, and look at what it learned, including one genuine mistake it makes and why. Along the way you will meet three ideas that apply to almost every machine learning system: learning from examples, the zero-probability trap, and the trade-off between false alarms and misses.

## The idea: learn which words give spam away

Look at a few spam messages and patterns jump out: "free", "click", "claim", "winner", "now". Normal messages talk about lunch, homework and football. A spam filter turns that intuition into numbers. For every word, it learns how often it appears in spam and how often in normal messages. For a new message, it combines the evidence from all its words into one probability.

The method is called **Naive Bayes**, after Thomas Bayes, whose rule for updating probabilities with new evidence it uses. It is called naive because it treats every word as independent, ignoring word order and context. That is obviously not true of language, yet it works surprisingly well. An influential 2002 essay by the programmer Paul Graham, *A Plan for Spam*, did much to popularise this approach for email.

## Step 1: examples to learn from

Machine learning starts with labelled examples. Here are 24 short training messages, 12 spam and 12 normal, plus 8 test messages kept separate so we can check the filter honestly. They were written for this example; real filters learn from millions of real messages.

**messages.py**

```python
# Short messages written for this example. 1 = spam, 0 = normal ("ham").
train = [
    ("Congratulations you have won a free prize claim it now", 1),
    ("You are a winner click here to claim your cash prize", 1),
    ("Free gift card waiting click the link now", 1),
    ("Urgent your account is locked click here to verify now", 1),
    ("Claim your free holiday winner selected today", 1),
    ("Earn cash fast from home click now limited offer", 1),
    ("Limited offer free phone for the first 100 winners", 1),
    ("Your parcel is waiting click the link to pay the fee", 1),
    ("Act now to claim your reward before it expires", 1),
    ("Hot deal free trial click here today only", 1),
    ("Verify your password now or your account will close", 1),
    ("You won cash click to collect your prize today", 1),
    ("Are we still meeting for lunch tomorrow", 0),
    ("Can you send me the notes from maths class", 0),
    ("Mum says dinner is at seven tonight", 0),
    ("The coding club meets in room 4 after school", 0),
    ("Thanks for the help with my homework yesterday", 0),
    ("Do not forget your football kit tomorrow", 0),
    ("I finished the Python project, want to see it", 0),
    ("Happy birthday, hope you have a great day", 0),
    ("The bus is late, I will be there in ten minutes", 0),
    ("Can you check my essay before I submit it", 0),
    ("Our team won the match today", 0),
    ("Please bring the library book back on Monday", 0),
]
test = [
    ("Click here now to claim your free prize", 1),
    ("Winner selected claim your cash today", 1),
    ("Your account will close, verify your password here", 1),
    ("Limited free offer for winners click now", 1),
    ("Can we meet after school to finish the project", 0),
    ("See you at football practice tomorrow", 0),
    ("I won the maths quiz today", 0),
    ("Dinner is ready, come home now", 0),
]
```

## Step 2: train and test the filter

Training is just counting. Classifying multiplies the chances of each word appearing in spam, and compares with the same for normal messages. To avoid numbers so small the computer rounds them to zero, the code adds logarithms instead of multiplying, which gives the same answer safely.

**bayes.py**

```python
import math, re
from collections import Counter
from messages import train, test

def words(text):
    return re.findall(r"[a-z]+", text.lower())

# Training: count how often each word appears in spam and in normal messages
counts = {1: Counter(), 0: Counter()}
docs = Counter()
for text, label in train:
    counts[label].update(words(text))
    docs[label] += 1
vocab = set(counts[1]) | set(counts[0])

def p_word(word, label):
    # add-one smoothing: a word never seen in this class still gets a small chance
    return (counts[label][word] + 1) / (sum(counts[label].values()) + len(vocab))

def spam_probability(text):
    log_score = {}
    for label in (1, 0):
        log_score[label] = math.log(docs[label] / len(train))
        for w in words(text):
            log_score[label] += math.log(p_word(w, label))
    # turn the two scores back into a probability between 0 and 1
    top = max(log_score.values())
    s, h = math.exp(log_score[1] - top), math.exp(log_score[0] - top)
    return s / (s + h)

if __name__ == "__main__":
    print(f"trained on {len(train)} messages, {len(vocab)} different words")
    spammy = sorted(vocab, key=lambda w: p_word(w, 1) / p_word(w, 0), reverse=True)[:6]
    print("most spam-like words:", ", ".join(spammy))

    correct = 0
    for text, label in test:
        p = spam_probability(text)
        guess = 1 if p > 0.5 else 0
        correct += guess == label
        print(f"{p:6.3f}  {'SPAM' if guess else 'ok  '}  {'right' if guess == label else 'WRONG'}  {text}")
    print(f"test accuracy: {correct} of {len(test)}")
```

**Output**

```text
trained on 24 messages, 121 different words
most spam-like words: click, now, free, claim, your, prize
 1.000  SPAM  right  Click here now to claim your free prize
 0.999  SPAM  right  Winner selected claim your cash today
 0.999  SPAM  right  Your account will close, verify your password here
 1.000  SPAM  right  Limited free offer for winners click now
 0.024  ok    right  Can we meet after school to finish the project
 0.029  ok    right  See you at football practice tomorrow
 0.145  ok    right  I won the maths quiz today
 0.835  SPAM  WRONG  Dinner is ready, come home now
test accuracy: 7 of 8
```

![Words the filter found most spam-like: click, now, free, claim, your, prize, each shown with how often it appears in spam versus normal messages](/images/blog/how-do-spam-filters-work/01-words.png)

*The filter was never told these words matter. It worked it out from the examples.*

From just 24 examples the filter picked out **click, now, free, claim, your** as the strongest signs of spam. Nobody told it; it counted. On the 8 test messages it got 7 of 8 right.

![Spam probabilities for eight unseen messages: the four spam messages score above 0.99, three normal messages score low, and "Dinner is ready, come home now" wrongly scores 0.83](/images/blog/how-do-spam-filters-work/02-results.png)

*The highlighted message is a false alarm, and it is an instructive one.*

## The mistake, and what it teaches

The filter marked "Dinner is ready, come home now" as spam with a probability of 0.83. Why? The word "now" appeared in many spam training messages ("click now", "act now") and never in a normal one. The filter has no idea that "come home now" is a perfectly ordinary thing to say. It only knows word counts.

This is how machine learning goes wrong in general: the model learns patterns from its training data, including accidental ones. More varied training data would fix this particular mistake, because real normal messages use "now" all the time. Our post on [why machine learning models make mistakes](/blog/why-do-machine-learning-models-make-mistakes) explores this with more experiments.

## The zero-probability trap

One detail in the code matters more than it looks: the `+ 1` in `p_word`. Without it, a word that never appeared in spam during training gets a probability of exactly zero, and multiplying by zero wipes out every other piece of evidence:

**smoothing.py**

```python
import re
from collections import Counter
from messages import train

counts = {1: Counter(), 0: Counter()}
for text, label in train:
    counts[label].update(re.findall(r"[a-z]+", text.lower()))
vocab = set(counts[1]) | set(counts[0])

def p_raw(word, label):          # no smoothing
    return counts[label][word] / sum(counts[label].values())

def p_smooth(word, label):       # add-one smoothing
    return (counts[label][word] + 1) / (sum(counts[label].values()) + len(vocab))

message = "click here to claim your free prize for your homework"
for name, p in [("without smoothing", p_raw), ("with smoothing", p_smooth)]:
    spam = ham = 0.5
    for w in message.split():
        spam *= p(w, 1)
        ham *= p(w, 0)
    verdict = "SPAM" if spam > ham else "ok"
    print(f"{name:<18} spam score {spam:.3g}  normal score {ham:.3g}  -> {verdict}")
print("'homework' seen in spam:", counts[1]["homework"], "times")
```

**Output**

```text
without smoothing  spam score 0  normal score 0  -> ok
with smoothing     spam score 4.5e-18  normal score 9.9e-23  -> SPAM
'homework' seen in spam: 0 times
```

![Without smoothing, the word homework, never seen in spam, makes the spam score exactly zero and wipes out every other clue; with add-one smoothing every word keeps a small chance and the message is correctly marked spam](/images/blog/how-do-spam-filters-work/03-smoothing.png)

*One unfamiliar word should weaken the evidence, not erase it.*

Without smoothing, both scores collapse to zero and the filter cannot decide, so a spam message slips through just by mentioning homework. Adding one imaginary sighting of every word to each class, called **add-one** or **Laplace smoothing**, fixes it. The same trick appears throughout machine learning whenever a model meets something it has never seen.

## False alarms versus misses

A filter outputs a probability, but it still has to decide: above what value do we call it spam? That choice is a trade-off:

**threshold.py**

```python
from bayes import spam_probability
from messages import test

for threshold in (0.5, 0.9, 0.999):
    false_alarms = sum(spam_probability(t) > threshold and label == 0 for t, label in test)
    missed = sum(spam_probability(t) <= threshold and label == 1 for t, label in test)
    print(f"threshold {threshold}: {false_alarms} normal message(s) flagged, {missed} spam missed")
```

**Output**

```text
threshold 0.5: 1 normal message(s) flagged, 0 spam missed
threshold 0.9: 0 normal message(s) flagged, 0 spam missed
threshold 0.999: 0 normal message(s) flagged, 1 spam missed
```

![Effect of the spam threshold on the test messages: at 0.5 one normal message is flagged and no spam missed; at 0.9 nothing is flagged wrongly and nothing missed; at 0.999 no false alarms but one spam missed](/images/blog/how-do-spam-filters-work/04-threshold.png)

*On a real inbox, losing a real message is usually worse than letting one spam through.*

At 0.5 the dinner message is wrongly flagged. At 0.9 everything is right on this small test set. Push the bar to 0.999 and a real spam message slips through. Real email services lean towards caution, because a lost job offer or school message costs far more than one extra junk email. It is the same false alarm problem that makes [AI detectors](/blog/do-ai-detectors-work) unreliable, and it comes straight from [basic probability](/blog/probability-for-beginners).

> **How real spam filters differ**

> Modern email services use much larger machine learning models, trained on huge numbers of messages, and combine the words with other signals such as the sender's reputation, links in the message and how other users have reported similar mail. The ideas in this post, learning from labelled examples and balancing false alarms against misses, still sit at the core.

## Try it yourself

1. Copy the three files and run `bayes.py`.
2. Add a few normal messages that use "now" and retrain. Does the dinner message stop being flagged?
3. Write a spam message designed to fool the filter, using only normal-looking words. What does that tell you about spammers?
4. Print the 6 most *normal* words, the ones most likely in normal messages.

> A spam filter does not understand language. It counts, and it is very good at counting.

## How we teach it

A spam filter is a good example of two principles on our [how we teach](/how-we-teach) page. Learning by building: forty lines of code teach more about machine learning than a page of definitions. And mistakes are data, not failures, which is exactly how the dinner-message false alarm should be read. Our [AI and machine learning course for teens](/ai-ml-course-for-teens) runs one to one or in small groups of 5 to 10.

[Book a free class](/book-demo) [Book a priority demo](/book-demo)

## Frequently asked questions

**How do spam filters work?**

They learn from labelled examples which words and features are common in spam and which are common in normal messages. For a new message, they combine the evidence into a probability of spam and move it to the spam folder if that probability is above a threshold.

**What is a Naive Bayes spam filter?**

It is a classic filter that uses Bayes' rule to combine the probability of each word appearing in spam and in normal messages. It is called naive because it treats every word as independent, ignoring order and context, yet it works well in practice.

**Why do real emails sometimes end up in spam?**

Because the filter judges patterns, not meaning. A normal message that happens to use words common in spam, like our example with the word now, can get a high spam score. That is called a false positive.

**What is Laplace smoothing?**

It adds one imaginary count of every word to each class, so no word ever has a probability of exactly zero. Without it, one word the filter has not seen in spam would wipe out all the other evidence.

**Is a spam filter artificial intelligence?**

Yes, a spam filter is a classic example of machine learning, a branch of AI where a system learns patterns from examples rather than following rules written by hand.

**Can I build a spam filter in Python?**

Yes. A simple Naive Bayes filter needs only word counts, a dictionary and some probability, about forty lines of Python, as shown in this post. It is a good first machine learning project.

**Why do spam filters use probabilities instead of rules?**

Spammers constantly change their wording, so fixed rules go out of date quickly. A probability-based filter can be retrained on new examples and weighs many clues together instead of relying on any single word.

---

*Source: https://learn.modernagecoders.com/blog/how-do-spam-filters-work/*
