← All tutorials
PythonMachine Learning

Cleaning Text Data for NLP: A Practical Guide to Messy Real-World Text

Real text data breaks in ways numeric data doesn't. This guide walks through cleaning a genuinely messy app-review dataset in pandas — missing and blank text fields, encoding artifacts, stray HTML, inconsistent casing and whitespace, and near-duplicate reviews — until it's ready to hand to a tokenizer.

Your NLP project doesn’t start with clean sentences. It starts with a CSV export, an API dump, or a folder of user-submitted text that’s never been near a tokenizer. Before you can ask “what is this document about,” you have to answer a much less glamorous question: is this DataFrame even usable? A review with no text attached, the same complaint typed twice with different punctuation, a stray <br> left over from a web form — none of that shows up in a tidy demo dataset, but all of it shows up the first time you point a real pipeline at real text.

This is the step most NLP write-ups skip, because it’s unglamorous and dataset-specific in a way tokenization isn’t — there’s no single function that fixes “messy text,” only a sequence of small, deliberate decisions. Our guide to NLP preprocessing in Python picks up right where this post ends: it assumes you already have a clean, one-document-per-row DataFrame and shows you how to tokenize, normalize, and vectorize it. This post is what happens before that, and it’s a different job from our general pandas data-cleaning guide, too — that post fixes numeric and categorical columns (missing names, inconsistent dates), while every problem here lives inside a single free-text column that a real person typed by hand.

The Mental Model: Recognizing Sameness, Not Just Fixing Errors

Numeric data mostly breaks by being wrong — a negative age, an impossible date. Text data breaks by being right in ten different disguises: “Great app!!” and “great app” and " Great app " all mean the same thing to a person, but they’re three different strings to a computer. Cleaning text is as much about teaching your code to recognize sameness as it is about fixing errors. Four ideas carry the rest of this post:

  1. Missing text hides in more shapes than NaN. An empty string, a string of only spaces, and a placeholder like "N/A" are all “nothing” — but only one of those trips .isna().
  2. Formatting noise multiplies how many ways the same sentence can be spelled. Casing, stray whitespace, leftover HTML, and encoding artifacts all do this, and none of them change what the text actually means.
  3. “Duplicate” text hides behind that same noise. Two people who typed the same opinion rarely typed it identically, so duplicate detection is only as strict — or as loose — as the normalization you run before it.
  4. Only once those are resolved do you have a DataFrame a tokenizer can trust. Every token, stem, and vector downstream inherits whatever mess survives this stage.
Diagram of the text-cleaning pipeline used in this post: 34 raw reviews go through fixing encoding and HTML, then handling missing and blank text drops 8 rows to 26, then deduplication removes 2 normalized duplicates and 1 near-duplicate to leave 23 clean rows ready for tokenization.

A Dataset You Can Reproduce

Imagine Loopnote, a small habit-tracking app, pulling user feedback together from three channels — App Store reviews, Google Play reviews, and a short in-app “how’s it going?” prompt — plus whatever support emails happen to mention the product. Nobody standardized the export format across those channels, so the feedback_text column looks exactly like real, hand-typed user text: some of it is shouted in capital letters, some of it never made it past a blank text box, and a few rows still carry the HTML and mis-decoded punctuation an old export script left behind.

This is an original, hand-written dataset — 34 rows, planted on purpose with the kinds of problems a real text export actually has, the same well-precedented approach our guide to Python data-cleaning libraries used for its vendor sign-up data. Every reader who pastes this block gets the identical 34 rows, no download required.

import re
import html
import difflib
import pandas as pd

reviews = pd.DataFrame([
    {"review_id": 1, "source": "App Store", "rating": 5, "feedback_text": "Loopnote finally got me to stick with a daily journaling habit. Highly recommend!"},
    {"review_id": 2, "source": "Google Play", "rating": 1, "feedback_text": "Crashes every time I try to add a new habit on my Pixel 7."},
    {"review_id": 3, "source": "Google Play", "rating": 2, "feedback_text": "  App is okay but notifications never fire on time.   "},
    {"review_id": 4, "source": "Email", "rating": 3, "feedback_text": "Its fine i guess. wish there were more themes"},
    {"review_id": 5, "source": "In-App Widget", "rating": 5, "feedback_text": "LOVE LOVE LOVE THIS APP!!! CHANGED MY MORNING ROUTINE COMPLETELY"},
    {"review_id": 6, "source": "App Store", "rating": 5, "feedback_text": "Best habit app I've tried. The streak view keeps me honest."},
    {"review_id": 7, "source": "Email", "rating": 2, "feedback_text": "Customer support never got back to me about my billing question."},
    {"review_id": 8, "source": "Google Play", "rating": 4, "feedback_text": "Widget on the home screen is a nice touch, checks habits off fast."},
    {"review_id": 9, "source": "App Store", "rating": 3, "feedback_text": "Good app &amp; easy to use, but the &quot;premium&quot; upsell is annoying."},
    {"review_id": 10, "source": "In-App Widget", "rating": 5, "feedback_text": "Great for building habits.<br>Just wish it synced with my smartwatch.<br/>"},
    {"review_id": 11, "source": "Google Play", "rating": 4, "feedback_text": "  great app!!  Love the reminders.   "},
    {"review_id": 12, "source": "App Store", "rating": 1, "feedback_text": "Doesn’t sync properly with Apple Health."},
    {"review_id": 13, "source": "Email", "rating": 5, "feedback_text": "The dark theme looks great — much easier on the eyes at night."},
    {"review_id": 14, "source": "App Store", "rating": 4, "feedback_text": None},
    {"review_id": 15, "source": "Google Play", "rating": 3, "feedback_text": ""},
    {"review_id": 16, "source": "In-App Widget", "rating": 5, "feedback_text": "   "},
    {"review_id": 17, "source": "Email", "rating": 3, "feedback_text": "N/A"},
    {"review_id": 18, "source": "App Store", "rating": 5, "feedback_text": "Best habit app I've tried, the streak view keeps me really honest."},
    {"review_id": 19, "source": "Google Play", "rating": 2, "feedback_text": "Reminders\tare\tinconsistent\ton\tAndroid\t14."},
    {"review_id": 20, "source": "App Store", "rating": 3, "feedback_text": "prettyÂ\xa0good overall, could use more widgets"},
    {"review_id": 21, "source": "In-App Widget", "rating": 4, "feedback_text": "Signed up for premium and it's honestly worth it."},
    {"review_id": 22, "source": "Google Play", "rating": 1, "feedback_text": "Lost three weeks of streak data after the last update. Not happy."},
    {"review_id": 23, "source": "App Store", "rating": 4, "feedback_text": None},
    {"review_id": 24, "source": "App Store", "rating": 2, "feedback_text": ""},
    {"review_id": 25, "source": "Google Play", "rating": 5, "feedback_text": "   "},
    {"review_id": 26, "source": "In-App Widget", "rating": 3, "feedback_text": "N/A"},
    {"review_id": 27, "source": "App Store", "rating": 4, "feedback_text": "Great app!! love the reminders."},
    {"review_id": 28, "source": "Email", "rating": 5, "feedback_text": "Customer support never got back to me about my billing question."},
    {"review_id": 29, "source": "In-App Widget", "rating": 2, "feedback_text": "The     spacing     between     habits     is     broken     on     iPad."},
    {"review_id": 30, "source": "Email", "rating": 4, "feedback_text": "Switched from a paper planner and honestly Loopnote is easier to stick with."},
    {"review_id": 31, "source": "App Store", "rating": 3, "feedback_text": "It's decent, but the free tier feels pretty limited now."},
    {"review_id": 32, "source": "In-App Widget", "rating": 1, "feedback_text": "Ads before every streak check-in. Ruins the whole experience."},
    {"review_id": 33, "source": "App Store", "rating": 4, "feedback_text": "Solid update this month, the calendar heat map is a nice addition."},
    {"review_id": 34, "source": "Email", "rating": 3, "feedback_text": "Good idea, rough execution — sync breaks constantly."},
])
reviews.shape
(34, 4)

Four columns, thirty-four rows. (The outputs in this post come from pandas 3.0 — the same approach works on pandas 2.x.)

Inspecting the Raw Mess: .info() and Blank-Looking Text

.info() is always the first move — dtypes and null counts, before you’ve formed any opinions about the data:

reviews.info()
<class 'pandas.DataFrame'>
RangeIndex: 34 entries, 0 to 33
Data columns (total 4 columns):
 #   Column         Non-Null Count  Dtype
---  ------         --------------  -----
 0   review_id      34 non-null     int64
 1   source         34 non-null     str  
 2   rating         34 non-null     int64
 3   feedback_text  32 non-null     str  
dtypes: int64(2), str(2)
memory usage: 1.2 KB

Two of the thirty-four feedback_text values are true NaN. Hold onto that number — it’s about to look too small.

blank_mask = reviews["feedback_text"].fillna("__NAN__").str.strip().isin(["", "N/A"])
blank_mask.sum()
6

.isna() only catches an actual null. Text has more ways to say “nothing” than a database column does: an empty string a user left in the box, three spaces they typed by accident, or a literal "N/A" someone put into an optional field. None of those trip .isna(), but all six of these rows are just as empty as the two real nulls:

    review_id         source feedback_text
14         15    Google Play              
15         16  In-App Widget              
16         17          Email           N/A
23         24      App Store              
24         25    Google Play              
25         26  In-App Widget           N/A

Combined with the two real nulls, that’s eight blank-in-some-way rows out of thirty-four — nearly a quarter of the dataset. A plain .isna().sum() alone would have missed three-quarters of them.

Casing is inconsistent too, and not just in a “some sentences start with a capital letter” way:

reviews[reviews["review_id"].isin([2, 5, 7])][["review_id", "feedback_text"]]
   review_id                                                     feedback_text
1          2        Crashes every time I try to add a new habit on my Pixel 7.
4          5  LOVE LOVE LOVE THIS APP!!! CHANGED MY MORNING ROUTINE COMPLETELY
6          7  Customer support never got back to me about my billing question.

Row 5 is entirely uppercase, row 2 and row 7 are ordinary sentence case, and elsewhere in the dataset there’s lowercase-only text too. A vectorizer that treats "Great" and "GREAT" and "great" as three different words will fragment your vocabulary for no good reason — that’s the case for normalizing, coming up next.

Fixing Encoding Artifacts and Stray HTML

A quick scan for HTML remnants and encoding artifacts turns up more problems than casing alone:

has_html = reviews["feedback_text"].str.contains(r"<[^>]+>|&\w+;", regex=True, na=False)
has_mojibake = reviews["feedback_text"].str.contains(r"[Ââ]€|Â\xa0", regex=True, na=False)
print("html-ish rows:", has_html.sum())
print("mojibake-ish rows:", has_mojibake.sum())
html-ish rows: 2
mojibake-ish rows: 4

The HTML rows are straightforward — an escaped ampersand, a couple of stray <br> tags:

   review_id                                                                feedback_text
8          9  Good app &amp; easy to use, but the &quot;premium&quot; upsell is annoying.
9         10   Great for building habits.<br>Just wish it synced with my smartwatch.<br/>

The mojibake rows are a different, sneakier problem — garbled text left behind when a file that was actually UTF-8 gets read as if it were Windows-1252 (a common mismatch in older CMS exports and Windows-authored CSVs). A right single quote turns into ’, an em dash turns into —, a stray non-breaking space turns into  followed by an invisible character:

    review_id                                                     feedback_text
11         12                        Doesn’t sync properly with Apple Health.
12         13  The dark theme looks great — much easier on the eyes at night.
19         20                      pretty good overall, could use more widgets
33         34            Good idea, rough execution — sync breaks constantly.

Both problems are fixable with the re module and the standard-library html module rather than anything exotic — html.unescape() turns &amp; back into &, and re-encoding as cp1252 then decoding as utf-8 reverses exactly the mismatch that produced the mojibake in the first place:

def fix_mojibake(text):
    if pd.isna(text):
        return text
    try:
        return text.encode("cp1252").decode("utf-8")
    except (UnicodeDecodeError, UnicodeEncodeError):
        return text

def strip_html(text):
    if pd.isna(text):
        return text
    text = html.unescape(text)
    text = re.sub(r"<[^>]+>", " ", text)
    return text

reviews["feedback_clean"] = reviews["feedback_text"].apply(fix_mojibake).apply(strip_html)
reviews[reviews["review_id"].isin([9, 10, 12, 13, 20, 34])][["review_id", "feedback_text", "feedback_clean"]]
    review_id                                                                feedback_text                                                       feedback_clean
8           9  Good app &amp; easy to use, but the &quot;premium&quot; upsell is annoying.        Good app & easy to use, but the "premium" upsell is annoying.
9          10   Great for building habits.<br>Just wish it synced with my smartwatch.<br/>  Great for building habits. Just wish it synced with my smartwatch. 
11         12                                   Doesn’t sync properly with Apple Health.                             Doesn’t sync properly with Apple Health.
12         13             The dark theme looks great — much easier on the eyes at night.       The dark theme looks great — much easier on the eyes at night.
19         20                                 pretty good overall, could use more widgets                          pretty good overall, could use more widgets
33         34                       Good idea, rough execution — sync breaks constantly.                 Good idea, rough execution — sync breaks constantly.

Doesn’t becomes Doesn't, &quot;premium&quot; becomes real quote marks, and the <br> tags disappear — the try/except in fix_mojibake matters because running the cp1252/utf-8 round trip on text that isn’t garbled would raise a UnicodeDecodeError, and this function has to run safely across rows that never had the problem in the first place.

Normalizing Whitespace Without Erasing Meaning

Whitespace is the next source of noise — leading and trailing spaces, doubled-up spaces, even stray tab characters from a copy-paste:

def collapse_whitespace(text):
    if pd.isna(text):
        return text
    return re.sub(r"\s+", " ", text).strip()

reviews["feedback_clean"] = reviews["feedback_clean"].apply(collapse_whitespace)
reviews[reviews["review_id"].isin([3, 11, 19, 29])][["review_id", "feedback_text", "feedback_clean"]]
    review_id                                                              feedback_text                                     feedback_clean
2           3                       App is okay but notifications never fire on time.     App is okay but notifications never fire on time.
10         11                                        great app!!  Love the reminders.                       great app!! Love the reminders.
18         19                             Reminders\tare\tinconsistent\ton\tAndroid\t14.          Reminders are inconsistent on Android 14.
28         29  The     spacing     between     habits     is     broken     on     iPad.      The spacing between habits is broken on iPad.

\s+ matches any run of whitespace — spaces, tabs, newlines — and collapses it to a single space, then .strip() removes what’s left at the ends. Notice feedback_clean still has its original casing and punctuation intact; we are not lowercasing or stripping punctuation from the column we’re keeping, only tidying its whitespace. That restraint is deliberate, and it’s worth a second, disposable column to see why:

def normalize_for_comparison(text):
    if pd.isna(text) or text == "":
        return text
    text = text.lower()
    text = re.sub(r"[^\w\s]", "", text)
    text = re.sub(r"\s+", " ", text).strip()
    return text

reviews["feedback_normalized"] = reviews["feedback_clean"].apply(normalize_for_comparison)
reviews[reviews["review_id"].isin([11, 27])][["review_id", "feedback_clean", "feedback_normalized"]]
    review_id                   feedback_clean           feedback_normalized
10         11  great app!! Love the reminders.  great app love the reminders
26         27  Great app!! love the reminders.  great app love the reminders

feedback_normalized lowercases everything and strips punctuation, which is exactly what you want for comparing two strings but exactly what you don’t want for the text you’ll actually tokenize later — more on that in the gotchas below. Keep feedback_clean as the column you ship, and treat feedback_normalized as scratch space for the deduplication work in the next two sections.

Handling Missing and Blank Text Fields

With the noise cleaned up, it’s time to act on the eight blank-in-some-way rows from earlier. Unify all four flavors — NaN, empty string, whitespace-only, and the literal "N/A" placeholder — into a single mask:

reviews["feedback_clean_stripped"] = reviews["feedback_clean"].fillna("")
is_missing = (
    reviews["feedback_text"].isna()
    | (reviews["feedback_clean_stripped"].str.strip() == "")
    | (reviews["feedback_clean_stripped"].str.strip().str.upper() == "N/A")
)
is_missing.sum()
8

A row with no text is useless to a tokenizer — there’s no linguistic content to normalize, stem, or vectorize, unlike a numeric column where a missing value can often be filled with a mean or a category label. Dropping is the right call here, but not before checking whether it costs you something you didn’t expect:

reviews.loc[is_missing, "source"].value_counts()
source
App Store        3
Google Play      2
In-App Widget    2
Email            1
Name: count, dtype: int64
reviews["source"].value_counts()
source
App Store        12
Google Play       8
Email             7
In-App Widget     7
Name: count, dtype: int64

As a share of each channel, that’s App Store 3/12 (25.0%), Google Play 2/8 (25.0%), In-App Widget 2/7 (28.6%), and Email 1/7 (14.3%) missing or blank. Email reviewers are noticeably less likely to leave the text blank than the other three channels — not dramatic in a 34-row toy dataset, but the kind of gap that would quietly skew a real one if a whole channel’s tone got underrepresented after dropping. Having checked, drop the eight rows:

clean = reviews.loc[~is_missing].copy()
len(reviews), len(clean)
(34, 26)

Finding Exact and Near-Duplicate Text

Duplicate text hides at three different strictness levels, and each one needs a looser comparison than the last. Start strict, with pandas’ own .duplicated() on the cleaned text:

clean["feedback_clean"].duplicated(keep=False).sum()
2

Only one pair matches exactly. But two reviews can express the same complaint without being byte-for-byte identical — casing, punctuation, and whitespace differences all defeat a raw string comparison. Comparing on feedback_normalized instead catches more:

norm_dup_mask = clean["feedback_normalized"].duplicated(keep=False)
norm_dup_mask.sum()
4
clean.loc[norm_dup_mask, ["review_id", "feedback_clean", "feedback_normalized"]].sort_values("feedback_normalized")
    review_id                                                    feedback_clean                                              feedback_normalized
6           7  Customer support never got back to me about my billing question.  customer support never got back to me about my billing question
27         28  Customer support never got back to me about my billing question.  customer support never got back to me about my billing question
10         11                                   great app!! Love the reminders.                                     great app love the reminders
26         27                                   Great app!! love the reminders.                                     great app love the reminders

Four rows now, two pairs: the exact “Customer support…” repeat the strict check already found, plus a second pair — " great app!! Love the reminders. " and "Great app!! love the reminders." — that only matches once whitespace and casing stop counting. drop_duplicates on that same column keeps the first occurrence of each pair and removes the rest:

before = len(clean)
clean = clean.drop_duplicates(subset="feedback_normalized", keep="first")
before, len(clean)
(26, 24)

That still isn’t the full picture. Some near-duplicates differ by more than casing and whitespace — a whole extra word can sneak in. feedback_normalized strips punctuation and case but leaves word choice untouched, so a straightforward similarity ratio from the standard library’s difflib.SequenceMatcher can compare every remaining pair and flag the ones that are almost, but not quite, the same sentence:

texts = clean[["review_id", "feedback_normalized"]].reset_index(drop=True)
near_pairs = []
for i in range(len(texts)):
    for j in range(i + 1, len(texts)):
        a = texts.loc[i, "feedback_normalized"]
        b = texts.loc[j, "feedback_normalized"]
        ratio = difflib.SequenceMatcher(None, a, b).ratio()
        if ratio >= 0.85:
            near_pairs.append((texts.loc[i, "review_id"], texts.loc[j, "review_id"], round(ratio, 3)))

pd.DataFrame(near_pairs, columns=["review_id_a", "review_id_b", "similarity"])
   review_id_a  review_id_b  similarity
0            6           18       0.941
clean.loc[clean["review_id"].isin([6, 18]), ["review_id", "feedback_clean"]]
   review_id                                                        feedback_clean
5           6         Best habit app I've tried. The streak view keeps me honest.
17         18  Best habit app I've tried, the streak view keeps me really honest.

Review 18 is review 6 restated with one extra word (“really”) and a comma instead of a period — a 94.1% similarity score, high enough to treat as the same opinion submitted twice, not two independent reviewers who happened to phrase things almost identically. Drop the longer restatement and keep the original:

clean = clean[clean["review_id"] != 18].copy()
len(clean)
23

The Clean DataFrame, Ready for Tokenization

Rename the working column back to something a downstream pipeline expects, and the dataset is done:

final = clean[["review_id", "source", "rating", "feedback_clean"]].rename(
    columns={"feedback_clean": "feedback_text"}
).reset_index(drop=True)
final.info()
<class 'pandas.DataFrame'>
RangeIndex: 23 entries, 0 to 22
Data columns (total 4 columns):
 #   Column         Non-Null Count  Dtype
---  ------         --------------  -----
 0   review_id      23 non-null     int64
 1   source         23 non-null     str  
 2   rating         23 non-null     int64
 3   feedback_text  23 non-null     str  
dtypes: int64(2), str(2)
memory usage: 868.0 bytes

Thirty-four raw rows became twenty-three clean ones: eight dropped for missing or blank text, two more collapsed as normalized duplicates, one more collapsed as a near-duplicate. Every remaining feedback_text value is non-null, has consistent whitespace, and has no leftover HTML or encoding artifacts — but it still has its original casing and punctuation, which is exactly the shape tokenization expects to start from: real sentences, not pre-mangled ones.

Four Gotchas Worth Knowing

Stripping punctuation from the text you keep can erase meaning you haven’t decided to lose yet. feedback_normalized intentionally strips punctuation and case for comparison — but running the same regex on feedback_clean would be a mistake:

row6_clean = reviews.loc[reviews["review_id"] == 6, "feedback_clean"].iloc[0]
too_aggressive = re.sub(r"[^\w\s]", "", row6_clean)
print("feedback_clean:  ", row6_clean)
print("over-stripped:   ", too_aggressive)
feedback_clean:   Best habit app I've tried. The streak view keeps me honest.
over-stripped:    Best habit app Ive tried The streak view keeps me honest

I've becomes Ive, and the sentence boundary between “tried” and “The” disappears along with the period. Both are recoverable right now, before you’ve decided what your tokenizer needs — they aren’t recoverable once you’ve overwritten the column that keeps them.

Lowercasing too early throws away signal you haven’t decided to discard. Review 5 is written entirely in capital letters — plausibly a genuine signal of strong sentiment, not noise:

row5_clean = reviews.loc[reviews["review_id"] == 5, "feedback_clean"].iloc[0]
print("feedback_clean:  ", row5_clean)
print("lowercased:      ", row5_clean.lower())
feedback_clean:   LOVE LOVE LOVE THIS APP!!! CHANGED MY MORNING ROUTINE COMPLETELY
lowercased:       love love love this app!!! changed my morning routine completely

Lowercase once, in place, and you can never again tell this review apart from one written in ordinary case. That’s exactly why the earlier normalization step only lowercases inside the throwaway feedback_normalized column — the decision to discard casing belongs to whatever model consumes the text next, not to the cleaning step.

Exact-duplicate checks miss near-duplicates, and can flag the wrong rows as duplicates in the first place. Before this post dropped the blank rows, a naive .duplicated() on the raw column found ten “duplicate” rows — but eight of those were just different blank and placeholder entries colliding with each other, not real repeated opinions, and the genuine "great app!! Love the reminders." repeat wasn’t in that list at all, because casing and whitespace still differed. Always resolve missing values before you dedupe, and always dedupe on normalized text, not raw text, if you want the second and third instance of the same opinion to actually match.

Dropping rows with missing text without checking whether that biases the dataset is easy to do by accident. The bias check earlier in this post found a real, if modest, gap — Email reviewers left blank feedback at 14.3%, versus 25–29% for the other three channels. Skip that check and you’d silently under-weight three channels’ worth of tone and vocabulary relative to Email’s, and you’d have no way to know it happened.

Wrapping Up

Every decision in this post followed the same shape: recognize that two things are the same before deciding what to do about them.

  • Fix encoding and HTML noise firsthtml.unescape() and a cp1252/utf-8 round trip, so later steps compare real characters, not garbled ones.
  • Normalize whitespace and casing in a separate comparison column, never in the text you keep, so decisions about case and punctuation stay reversible.
  • Unify every shape of “missing”NaN, empty string, whitespace-only, placeholder text — before deciding to drop, and check which subgroups you’re dropping from.
  • Dedupe in three passes: exact, then normalized-exact, then similarity-based, because each one catches a different flavor of “the same review twice.”

Once your text is clean and consistent, the natural next step is turning it into something a model can use — our guide to NLP preprocessing in Python picks up exactly here, with tokenization, stopword removal, and TF-IDF vectorization. For the fuller course context — string operations, missing data, and duplicate handling as a connected sequence with exercises — the Pandas Data Analysis module in our free Python for Data Analytics course covers this material in depth.

More tutorials