Word count matters more than you might think. Blog editors set minimums for SEO. Academic submissions have strict limits. Contracts specify page lengths. Proofreaders bill by word count. And any time you paste text into a tool and click "Count Words", you're probably making an assumption about what "a word" actually means.
Here's how word counters work, how to build one in code, and the edge cases that break naive implementations.
What does "a word" mean?
Intuitively, a word is a sequence of characters separated by whitespace. That definition works for most English text — but it breaks down quickly:
| Input | Naive count | Better count |
|---|---|---|
"Hello, world!" |
2 | 2 |
"it's a good day" |
4 | 4 |
"café résumé" |
2 | 2 |
"2024-01-15" |
1 | 1 (date) or 3 (parts)? |
"C++ rocks" |
2 | 2 |
"北京 上海" |
2 | 2 (space-separated CJK) |
"你好世界" |
1 | 4 (CJK, each char = word) |
"" |
0 | 0 |
" " |
0 | 0 |
The most common definition — and the one used by most editors and writing tools — is: split on whitespace, discard empty tokens. That's it. Punctuation attached to words (commas, periods, quotes) stays attached and doesn't create separate tokens.
Counting words, characters, and lines
A full word counter typically tracks four metrics:
- Words — whitespace-split tokens (empty tokens discarded)
- Characters — total length including spaces and punctuation
- Characters without spaces — total length excluding whitespace
- Lines — number of newline-delimited lines (empty lines count)
JavaScript
function countText(text) {
const words = text.trim() === '' ? 0 : text.trim().split(/\s+/).length;
const chars = text.length;
const charsNoSpaces = text.replace(/\s/g, '').length;
const lines = text === '' ? 0 : text.split('\n').length;
return { words, chars, charsNoSpaces, lines };
}
// Usage
const result = countText("Hello, world!\nThis is a test.");
// { words: 6, chars: 30, charsNoSpaces: 24, lines: 2 }
Key detail: text.trim().split(/\s+/) handles multiple spaces, tabs, and newlines between words. Without trim(), a leading space creates an empty first token and inflates the count by 1.
Python
def count_text(text: str) -> dict:
words = len(text.split()) # str.split() handles all whitespace + strips
chars = len(text)
chars_no_spaces = len(text.replace(' ', '').replace('\t', '').replace('\n', ''))
lines = len(text.splitlines()) if text else 0
return {
"words": words,
"chars": chars,
"chars_no_spaces": chars_no_spaces,
"lines": lines,
}
result = count_text("Hello, world!\nThis is a test.")
# {'words': 6, 'chars': 30, 'chars_no_spaces': 24, 'lines': 2}
str.split() with no arguments is Python's built-in word splitter — it handles any whitespace and strips leading/trailing space automatically. It's equivalent to text.strip().split(/\s+/) in JS.
Go
package main
import (
"strings"
"unicode"
)
type TextCount struct {
Words int
Chars int
CharsNoSpace int
Lines int
}
func CountText(text string) TextCount {
words := 0
if strings.TrimSpace(text) != "" {
words = len(strings.Fields(text)) // Fields splits on any whitespace
}
chars := len([]rune(text)) // rune count, not byte count (handles Unicode)
charsNoSpace := 0
for _, r := range text {
if !unicode.IsSpace(r) {
charsNoSpace++
}
}
lines := 0
if text != "" {
lines = strings.Count(text, "\n") + 1
}
return TextCount{words, chars, charsNoSpace, lines}
}
Important: Use len([]rune(text)) for character count, not len(text). In Go, len() on a string returns the byte count, not the character count. For a string containing "café", len("café") is 5 (the é is two bytes in UTF-8), but len([]rune("café")) is 4.
PHP
function countText(string $text): array {
$words = $text === '' ? 0 : str_word_count($text);
$chars = mb_strlen($text);
$charsNoSpaces = mb_strlen(preg_replace('/\s/u', '', $text));
$lines = $text === '' ? 0 : count(explode("\n", $text));
return [
'words' => $words,
'chars' => $chars,
'chars_no_spaces' => $charsNoSpaces,
'lines' => $lines,
];
}
$result = countText("Hello, world!\nThis is a test.");
// ['words' => 6, 'chars' => 30, 'chars_no_spaces' => 24, 'lines' => 2]
Note on str_word_count(): This function uses locale-aware word splitting. Pass the text as the first argument with no format argument (or 0) to get the integer count. It handles punctuation attached to words correctly (treats "it's" as one word), but it can misbehave with non-Latin scripts — for those, fall back to preg_split('/\s+/u', trim($text)).
Word frequency counting
Beyond raw count, many tools show which words appear most often. This is useful for SEO keyword density checks and spotting repetition in your writing.
function wordFrequency(text) {
const words = text.toLowerCase().match(/\b\w+\b/g) ?? [];
const freq = {};
for (const word of words) {
freq[word] = (freq[word] ?? 0) + 1;
}
// Sort by frequency descending
return Object.entries(freq).sort((a, b) => b[1] - a[1]);
}
wordFrequency("the cat sat on the mat the cat");
// [['the', 3], ['cat', 2], ['sat', 1], ['on', 1], ['mat', 1]]
from collections import Counter
import re
def word_frequency(text: str) -> list[tuple[str, int]]:
words = re.findall(r'\b\w+\b', text.lower())
return Counter(words).most_common()
Quick reference
| Task | JavaScript | Python | Go | PHP |
|---|---|---|---|---|
| Count words | text.trim().split(/\s+/).length |
len(text.split()) |
len(strings.Fields(text)) |
str_word_count($text) |
| Count chars | text.length |
len(text) |
len([]rune(text)) |
mb_strlen($text) |
| Count chars (no spaces) | text.replace(/\s/g,'').length |
len(text.split()) |
range + unicode.IsSpace |
mb_strlen(preg_replace('/\s/u','', $text)) |
| Count lines | text.split('\n').length |
len(text.splitlines()) |
strings.Count(text,"\n")+1 |
count(explode("\n",$text)) |
| Word frequency | match(/\b\w+\b/g) + Map |
Counter(re.findall(...)) |
map[string]int loop |
array_count_values |
6 common mistakes
1. Counting empty strings as one word.
"".split(" ") in JavaScript returns [""] — an array with one empty string. The fix: text.trim() === '' ? 0 : text.trim().split(/\s+/).length.
2. Using len(string) in Go for character count.
In Go, len("café") returns 5 (bytes), not 4 (characters). Always use len([]rune(text)) or utf8.RuneCountInString(text) for character counts in Go.
3. Not handling multiple consecutive spaces.
"hello world".split(" ") gives ["hello", "", "", "world"] — two phantom empty tokens. Use split(/\s+/) (regex) or split() (Python, which handles this automatically).
4. Forgetting Unicode in PHP.
PHP's strlen() counts bytes, not characters. For any text that might contain accented characters or emoji, always use mb_strlen(). Similarly, use mb_substr() for slicing.
5. Treating line count as paragraph count.
An empty line between paragraphs adds to line count. If you want paragraph count, split on \n\n (double newlines) and count non-empty chunks.
6. Assuming English word boundaries for CJK text.
In Chinese, Japanese, and Korean, words are not space-separated. For accurate CJK word counts, you need a dedicated tokenizer (e.g., jieba for Chinese, MeCab for Japanese). A whitespace splitter will count entire sentences as single "words".
FAQ
Why do different tools give different word counts? They use different rules for punctuation, hyphenated words, and numbers. Microsoft Word counts "it's" as one word. Some tools count "2024-01-15" as one word; others split it on hyphens into three. There's no universal standard.
Does punctuation attached to a word count as part of it? In most tools, yes — "Hello," is treated as one word. The punctuation doesn't create a separate token. Only whitespace (spaces, tabs, newlines) creates word boundaries.
What's the difference between characters and bytes?
A character is a Unicode code point (a letter, digit, or symbol). A byte is a unit of storage. ASCII characters (A–Z, 0–9, common punctuation) are one byte each in UTF-8. Accented characters like é are two bytes. Emoji like 😀 are four bytes. Users think in characters; your database and strlen() think in bytes.
How do I count words in a file instead of a string?
In Python: len(open('file.txt').read().split()). In Go: read with os.ReadFile, convert to string, then strings.Fields(). In PHP: str_word_count(file_get_contents('file.txt')). For very large files (>100 MB), stream line by line to avoid loading the entire file into memory.
How do I exclude stop words from frequency counts?
Define a set of common words to ignore (["the", "a", "is", "in", ...]) and filter them out before counting: words.filter(w => !stopWords.has(w)) in JavaScript, or [w for w in words if w not in stop_words] in Python.
What's the fastest way to count words in a very large document?
Stream the file line by line and accumulate counts. In Python, wc -w (the Unix command) is the fastest option for multi-gigabyte files — it's implemented in C and reads the file in buffered chunks. For cross-platform programmatic use, process line by line using a buffered reader.