Help us shape the future of Go! Take the 2026 Go Developer Survey

profanity

package module
v1.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 7, 2026 License: MIT Imports: 9 Imported by: 0

README

go-profanity

CI codecov Go Report Card Go Reference

A fast Go port of better_profanity: censor swear words in text, resolving leetspeak character substitutions and multi-word phrases.

  • Detects leetspeak variants: p*ssy, 5h1t, m0th3rf*cker, H4nd j0b...
  • Multi-word phrases, with or without separators: 2 girls 1 cup, hand_job
  • Unicode-aware (Cyrillic, Vietnamese, combining marks...)
  • Custom word lists, whitelists, custom censor character
  • Pure Go, zero dependencies, default word list embedded in the binary

The original library compares every token against every censor word. This port matches tokens against a character-substitution trie in O(len(token)), which makes censoring ~1,000–4,000× faster than the Python original while producing byte-identical output (validated by a 400-case differential test against better_profanity, plus the full ported test suite).

Install

go get github.com/roger-russel/go-profanity

Usage

package main

import (
    "fmt"

    profanity "github.com/roger-russel/go-profanity"
)

func main() {
    // Package-level helpers use the embedded default word list (835 entries).
    fmt.Println(profanity.Censor("That wh0re gave m3 a very good H4nd j0b"))
    // That **** gave m3 a very good ****

    fmt.Println(profanity.ContainsProfanity("he is a m0th3rf*cker"))
    // true

    // Or build an instance:
    p, _ := profanity.New()
    fmt.Println(p.CensorWith("Holy shit!", '-'))
    // Holy ----!
}
Custom word list
p, _ := profanity.New(profanity.WithWords("happy", "jolly", "merry"))
p.ContainsProfanity("Have a merry day!") // true

// From a file (one word or phrase per line):
p, err := profanity.New(profanity.WithWordListFile("wordlist.txt"))

// Add words to the current list:
p.AddCensorWords("supremacia ariana")
Whitelist
p, _ := profanity.New(profanity.WithWhitelist("boobs"))
p.Censor("I have boobs") // unchanged
Phonetic normalization

Accented letters are folded automatically and always on (cuzão matches a registered cuzao). Common Portuguese phonetic misspellings — where the substitution isn't one accented letter but a different sequence of letters entirely — are opt-in via WithPhoneticNormalization, since folding them increases the chance of matching unrelated words that happen to share those letters:

p, _ := profanity.New(
    profanity.WithWordListFile("bad-words/pt-BR.txt"),
    profanity.WithPhoneticNormalization(),
)
p.ContainsProfanity("cuzaum") // true, matches registered "cuzao"
p.ContainsProfanity("cusão")  // true
p.ContainsProfanity("cusaum") // true

It applies to every censor word, not just a hardcoded list — whatever is registered (default words, WithWords, WithWordListFile, AddCensorWords, FeedWords) gets matched with the same folding. The currently supported substitutions are the phoneticDigraphs table in chars.go:

Folded pair Example match
z / s cuzão ≡ cusão
aum / ão cuzaum ≡ cuzão
ph / f phoda ≡ foda
pf / f pfoda ≡ foda

To add a new digraph, append a {from, to} pair to phoneticDigraphs in chars.go — no other code needs to change. See Phonetic normalization cost below for the performance trade-off.

Separator collapse

Another common evasion: spelling a word out one letter at a time with punctuation between each letter (c.u, c..u, a.n.a.l). WithSeparatorCollapse collapses those into the plain word before matching — any amount of repeated punctuation works, and it isn't a fixed list of separators: any character that isn't already a word character and isn't whitespace counts (whitespace is left alone on purpose, so phrase matching like hand job keeps working exactly as before):

p, _ := profanity.New(
    profanity.WithWords("cu", "anal"),
    profanity.WithSeparatorCollapse(),
)
p.ContainsProfanity("c.u")     // true
p.ContainsProfanity("c..u")    // true, any repeat count works
p.ContainsProfanity("a.n.a.l") // true — not limited by phrase lookahead depth

It only collapses runs where every segment between separators is exactly one character — "c.up" (segment "up" has 2 letters) is left as two ordinary tokens, not merged into "cup". This keeps it narrowly scoped to letter-by-letter spelling, but it isn't foolproof: punctuation-spaced abbreviations that happen to spell a censor word (e.g., U.S.A.-style initials) can collapse too, e.g. a,b,c collapses to abc. Disabled by default for that reason.

Dynamic updates via channel

New words can be fed at runtime — no need to rebuild the instance. Scans already in flight finish with the word list they started with; scans started after the update see the new words:

p, _ := profanity.New()

feed := p.NewWordFeed(64) // starts a background consumer
defer close(feed)         // stops it; pending words are still applied

feed <- "newbadword"
feed <- "some bad phrase"
// shortly after: p.ContainsProfanity("newbadword") == true

To own the channel/goroutine yourself, use FeedWords:

ch := make(chan string, 64)
go p.FeedWords(ch) // returns when ch is closed

Feeding is asynchronous; when a word must be active before the next scan, call p.AddCensorWords("word") directly — it is goroutine-safe too.

Concurrency

All methods are safe for concurrent use. Readers (Censor, CensorWith, ContainsProfanity, Len) are lock-free: each scan atomically grabs an immutable snapshot of the word list. Writers (Load*, AddCensorWords, FeedWords) are serialized internally and publish a new snapshot, so updates never block or corrupt in-flight scans. Bursts sent through a word feed are batched into a single snapshot rebuild (~0.3 ms for the default list).

Performance

i9-11900H, Go 1.26 vs CPython 3.13 — censoring the ~630-byte benchmark paragraphs from better_profanity's own test suite:

Case better_profanity (Python) go-profanity Speedup
Censor, clean text 53,586 µs 14.3 µs ~3,700×
Censor, 100% profane 30,887 µs 22.8 µs ~1,350×
Contains, clean text 51,224 µs 11.3 µs ~4,500×
Contains, 5% profane (early exit) — 1.8 µs —

Reproduce with:

go test -bench . -benchmem
Phonetic normalization cost

WithPhoneticNormalization (folds z↔s, ph/pf→f, aum→ão-spelled ao, so e.g. cuzaum/cusão match a registered cuzao) is opt-in because of the per-token string replacement it adds. Measured with benchstat, 10 runs, same machine as above:

Benchmark flag off flag on Δ
ContainsProfanity, 50% 2.37 µs 2.89 µs +21.9%
New (default wordlist) 487.3 µs 561.1 µs +15.2%

With the flag left off (the default), the accent-folding and digraph machinery add no measurable cost: comparing the same benchmarks before and after introducing them showed a +0.45% geomean, well within noise (most cases showed no statistically significant difference; two showed a ~2% change).

Why not regex / Hyperscan?

better_profanity does not use regex — it tokenizes and matches whole words with character substitutions, and its semantics (word boundaries defined by allowed characters, whole-token censoring, phrase lookahead) do not map cleanly onto substring regex matching. The trie scanner implements those semantics exactly, in pure Go.

For the curious, benchmarks/hyperscan/ contains an opt-in benchmark comparing raw multi-pattern scan throughput against Intel Hyperscan via gohs (requires libhyperscan and cgo; note Hyperscan reports substring matches, i.e. weaker semantics):

# Arch: pacman -S vectorscan | Debian/Ubuntu: apt install libhyperscan-dev
cd benchmarks/hyperscan && go test -bench . -benchmem

Semantics notes (inherited from better_profanity)

  • Words are compared in lowercase; censor words are stored lowercase.
  • A censored word is always replaced by exactly four censor characters.
  • "Allowed" (word) characters are unicode letters, combining marks, ASCII digits and @ $ * " '. Everything else separates words.
  • Phrase matching combines a token with up to N following words, where N is the maximum number of separators in any censor word (3 in the default list, because of 2 girls 1 cup).

License

MIT — see LICENSE. Word list and algorithm semantics from better_profanity (MIT, © Son Nguyen Kim), itself derived from profanity (© Ben Friedland).

Documentation

Overview

Package profanity is a fast Go port of the Python better_profanity library (https://github.com/snguyenthanh/better_profanity).

It censors swear words in text, resolving common leetspeak character substitutions (p*ssy, 5h1t, w0rse...) and multi-word phrases ("2 girls 1 cup", "hand job") including variants with separators removed or kept.

Instead of comparing every token against every censor word like the original, it matches tokens against a trie in O(len(token)), which makes it orders of magnitude faster while keeping the exact same semantics.

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func Censor

func Censor(text string) string

Censor replaces every swear word with "****" using the default word list.

Example
package main

import (
	"fmt"

	profanity "github.com/roger-russel/go-profanity"
)

func main() {
	fmt.Println(profanity.Censor("Holy shit!"))
}
Output:
Holy ****!

func ContainsProfanity

func ContainsProfanity(text string) bool

ContainsProfanity reports whether text contains any swear word from the default word list.

Example
package main

import (
	"fmt"

	profanity "github.com/roger-russel/go-profanity"
)

func main() {
	fmt.Println(profanity.ContainsProfanity("he is a m0th3rf*cker"))
	fmt.Println(profanity.ContainsProfanity("hello world"))
}
Output:
true
false

func DefaultWords

func DefaultWords() []string

DefaultWords returns a copy of the embedded default censor word list.

Example
package main

import (
	"fmt"

	profanity "github.com/roger-russel/go-profanity"
)

func main() {
	fmt.Println(len(profanity.DefaultWords()))
}
Output:
835

Types

type Option

type Option func(*config)

Option configures New and the Load* methods.

func WithPhoneticNormalization

func WithPhoneticNormalization() Option

WithPhoneticNormalization enables folding of common Portuguese phonetic misspellings (z/s, ph/pf -> f, "aum" -> nasal "ão") so alternate spellings match the same censor word (e.g. "cuzaum", "cusão" match a registered "cuzao"). Disabled by default: it increases the chance of matching unrelated words that happen to share these letters.

func WithSeparatorCollapse

func WithSeparatorCollapse() Option

WithSeparatorCollapse enables collapsing letters that are spelled out one at a time with punctuation between them — e.g. "c.u", "c..u", "a.n.a.l" — so they match the same censor word as "cu"/"anal". Any character that isn't already a word character and isn't whitespace counts as this kind of separator (whitespace keeps its existing phrase-lookahead behavior, e.g. "hand job", untouched). Disabled by default: it increases the chance of matching unrelated punctuation-spaced initials or abbreviations.

func WithWhitelist

func WithWhitelist(words ...string) Option

WithWhitelist excludes the given words from the censor list.

func WithWordListFile

func WithWordListFile(path string) Option

WithWordListFile makes New load censor words from a file with one word or phrase per line (blank lines are ignored). It is ignored by the Load* methods.

func WithWords

func WithWords(words ...string) Option

WithWords makes New load the given censor words instead of the default word list. It is ignored by the Load* methods, which already receive their words.

type Profanity

type Profanity struct {
	// contains filtered or unexported fields
}

Profanity censors swear words in text. Create it with New; the zero value is usable but censors nothing until words are loaded.

All methods are safe for concurrent use: readers (Censor, CensorWith, ContainsProfanity, Len) never block, and writers (Load*, AddCensorWords, FeedWords) are serialized internally and become visible to subsequent scans atomically. In-flight scans keep the word list they started with.

func MustNew

func MustNew(opts ...Option) *Profanity

MustNew is New, panicking on error. Handy for package-level variables.

func New

func New(opts ...Option) (*Profanity, error)

New returns a Profanity loaded with the default word list, or with the words selected by the given options.

Example (CustomWords)
package main

import (
	"fmt"

	profanity "github.com/roger-russel/go-profanity"
)

func main() {
	p, err := profanity.New(profanity.WithWords("happy", "jolly", "merry"))
	if err != nil {
		panic(err)
	}
	fmt.Println(p.Censor("Have a merry day!"))
	fmt.Println(p.ContainsProfanity("Fuck you!")) // not in the custom list
}
Output:
Have a **** day!
false
Example (PhoneticNormalization)
package main

import (
	"fmt"

	profanity "github.com/roger-russel/go-profanity"
)

func main() {
	p, err := profanity.New(
		profanity.WithWords("cuzao"),
		profanity.WithPhoneticNormalization(),
	)
	if err != nil {
		panic(err)
	}
	fmt.Println(p.ContainsProfanity("cuzao"))  // as registered
	fmt.Println(p.ContainsProfanity("cuzaum")) // "aum" folds to "ão"
	fmt.Println(p.ContainsProfanity("cusão"))  // "s"/"z" fold together
}
Output:
true
true
true
Example (SeparatorCollapse)
package main

import (
	"fmt"

	profanity "github.com/roger-russel/go-profanity"
)

func main() {
	p, err := profanity.New(
		profanity.WithWords("cu"),
		profanity.WithSeparatorCollapse(),
	)
	if err != nil {
		panic(err)
	}
	fmt.Println(p.ContainsProfanity("c.u"))   // letters spelled out with punctuation
	fmt.Println(p.ContainsProfanity("c...u")) // any amount of repeated separator
	fmt.Println(p.ContainsProfanity("hello world"))
}
Output:
true
true
false
Example (Whitelist)
package main

import (
	"fmt"

	profanity "github.com/roger-russel/go-profanity"
)

func main() {
	p, err := profanity.New(profanity.WithWhitelist("boobs"))
	if err != nil {
		panic(err)
	}
	fmt.Println(p.Censor("I have boobs"))
}
Output:
I have boobs

func (*Profanity) AddCensorWords

func (p *Profanity) AddCensorWords(words ...string)

AddCensorWords adds words to the current censor list. The new words are visible to scans started after it returns.

Example
package main

import (
	"fmt"

	profanity "github.com/roger-russel/go-profanity"
)

func main() {
	p := profanity.MustNew()
	p.AddCensorWords("supremacia ariana")
	fmt.Println(p.Censor("chega de supremacia ariana"))
}
Output:
chega de ****

func (*Profanity) Censor

func (p *Profanity) Censor(text string) string

Censor replaces every swear word in text with "****".

Example
package main

import (
	"fmt"

	profanity "github.com/roger-russel/go-profanity"
)

func main() {
	p := profanity.MustNew()

	// Leetspeak variants and multi-word phrases are resolved.
	fmt.Println(p.Censor("That wh0re gave m3 a very good H4nd j0b."))
}
Output:
That **** gave m3 a very good ****.

func (*Profanity) CensorWith

func (p *Profanity) CensorWith(text string, censorChar rune) string

CensorWith replaces every swear word in text with censorChar repeated four times.

Example
package main

import (
	"fmt"

	profanity "github.com/roger-russel/go-profanity"
)

func main() {
	p := profanity.MustNew()
	fmt.Println(p.CensorWith("Holy shit!", '-'))
}
Output:
Holy ----!

func (*Profanity) ContainsProfanity

func (p *Profanity) ContainsProfanity(text string) bool

ContainsProfanity reports whether text contains any swear word. It stops at the first match, without building the censored text.

Example
package main

import (
	"fmt"

	profanity "github.com/roger-russel/go-profanity"
)

func main() {
	p := profanity.MustNew()
	fmt.Println(p.ContainsProfanity("2 girls 1 cup is my favorite video"))
}
Output:
true

func (*Profanity) FeedWords

func (p *Profanity) FeedWords(ch <-chan string)

FeedWords consumes censor words (or phrases) from ch and adds them to the censor list, until ch is closed. It blocks, so run it in its own goroutine:

ch := make(chan string, 64)
go p.FeedWords(ch)
ch <- "newword" // picked up by scans shortly after

Words already sitting in the channel are drained and applied as a single batch, so bursts cost one snapshot rebuild instead of one per word. Delivery is asynchronous: a word sent to ch becomes active shortly after, but not necessarily before the send returns. When a word must be active before the next scan, call AddCensorWords directly instead — it is also safe for concurrent use.

Example
package main

import (
	"fmt"

	profanity "github.com/roger-russel/go-profanity"
)

func main() {
	p := profanity.MustNew()

	ch := make(chan string, 2)
	ch <- "blorp"
	ch <- "frase proibida"
	close(ch)

	// FeedWords returns once ch is closed and all pending words are applied.
	// In a long-lived program, run it in a goroutine instead:
	//
	//	go p.FeedWords(ch)
	p.FeedWords(ch)

	fmt.Println(p.Censor("what a blorp day"))
	fmt.Println(p.ContainsProfanity("uma frase proibida aqui"))
}
Output:
what a **** day
true

func (*Profanity) Len

func (p *Profanity) Len() int

Len returns the number of words in the censor list.

func (*Profanity) LoadCensorWords

func (p *Profanity) LoadCensorWords(words []string, opts ...Option)

LoadCensorWords replaces the censor list with the given words.

Example
package main

import (
	"fmt"

	profanity "github.com/roger-russel/go-profanity"
)

func main() {
	p := profanity.MustNew()
	p.LoadCensorWords([]string{"bobagem"}) // replaces the default list
	fmt.Println(p.Censor("que bobagem!"))
	fmt.Println(p.ContainsProfanity("Holy shit!"))
}
Output:
que ****!
false

func (*Profanity) LoadCensorWordsFromFile

func (p *Profanity) LoadCensorWordsFromFile(path string, opts ...Option) error

LoadCensorWordsFromFile replaces the censor list with the words read from a file with one word or phrase per line.

func (*Profanity) LoadDefaultCensorWords

func (p *Profanity) LoadDefaultCensorWords(opts ...Option)

LoadDefaultCensorWords replaces the censor list with the embedded default word list.

func (*Profanity) NewWordFeed

func (p *Profanity) NewWordFeed(buffer int) chan<- string

NewWordFeed starts a background consumer and returns the channel to send new censor words on. Close the channel to stop the consumer; any words already sent are still applied.

Example
package main

import (
	profanity "github.com/roger-russel/go-profanity"
)

func main() {
	p := profanity.MustNew()

	feed := p.NewWordFeed(64)
	defer close(feed) // stops the background consumer

	// Words become active shortly after being sent, without blocking
	// concurrent Censor/ContainsProfanity calls.
	feed <- "newbadword"
}

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL