How to Check if a String Contains a Substring in Rust

Use the `contains()` method on the `&str` type to check for a substring, which returns a boolean.

Story opener

You're parsing a log file line by line. A line arrives: 2024-05-20 ERROR: connection timeout. You need to flag this line because it contains the word ERROR. In Python, you'd write "ERROR" in line. In JavaScript, line.includes("ERROR"). Rust has the same intuition. You reach for .contains(). The syntax is familiar, but the mechanics under the hood reveal how Rust handles text safely and efficiently.

Concept in plain words

Rust strings come in two forms. String is an owned, growable buffer that lives on the heap. &str is a borrowed slice that points to a chunk of UTF-8 bytes stored somewhere else. The contains method lives on &str. It scans the bytes for a pattern. If it finds the sequence, it returns true. If it reaches the end without a match, it returns false.

Think of contains like scanning a page with a highlighter. You slide the highlighter across the text. If the pattern under the highlighter matches what you're looking for, you stop and report success. If you reach the bottom of the page, you report failure. The method never modifies the text. It never allocates new memory. It just reads.

Minimal example

fn main() {
    // &str is a borrowed slice. It points to data elsewhere.
    // String literals are &str with static lifetime.
    let text = "Rust is a systems programming language";
    let search_term = "systems";

    // contains() returns a bool. No allocation happens here.
    // The method works on &str, so it accepts both &str and String
    // via deref coercion.
    if text.contains(search_term) {
        println!("Found: {}", search_term);
    } else {
        println!("Not found");
    }

    // contains() also accepts a char.
    // This is possible because char implements the Pattern trait.
    if text.contains('R') {
        println!("Contains the character 'R'");
    }
}

Walkthrough: bytes, UTF-8, and the Pattern trait

When you call text.contains(search_term), the compiler resolves this to a method on &str. The signature is generic: fn contains<P: Pattern>(&self, pat: P) -> bool. The Pattern trait is the key. It allows contains to accept different types of patterns without duplicating code. &str implements Pattern. char implements Pattern. Slices of chars implement Pattern. Even compiled regular expressions implement Pattern.

At runtime, the implementation iterates over the UTF-8 bytes of text. Rust strings are always valid UTF-8. The contains method respects UTF-8 boundaries. It will never match a pattern that splits a multi-byte character in half. If you search for "é", the method looks for the byte sequence of that codepoint. If the text contains "é" encoded as a single codepoint, it matches. If the text contains "e" followed by a combining acute accent, the byte sequence differs, and the match fails. contains does not perform Unicode normalization. It matches bytes.

The method uses an optimized search algorithm. For simple substrings, it often employs a variant of Boyer-Moore or similar techniques that skip bytes when mismatches occur. This makes contains fast for typical workloads. You don't need to worry about performance for standard substring checks. The standard library implementation is tuned.

Convention aside: The community prefers passing &str to functions rather than String. When you write a helper that searches text, take &str as the argument. This avoids unnecessary clones and allows the caller to pass either a String or a string literal without friction.

/// Checks if the text contains the search term.
/// Takes &str to avoid ownership requirements.
fn has_term(text: &str, term: &str) -> bool {
    text.contains(term)
}

Realistic example: case-insensitive search and regex

Real code rarely searches for exact case matches. Users type "rust", "Rust", "RUST". You need case-insensitive search. The naive approach is to_lowercase(). This converts the string to lowercase and then calls contains.

fn main() {
    let text = "Rust is fast";

    // to_lowercase() allocates a new String.
    // It must allocate because lowercasing can change the length.
    // For example, German "ß" becomes "ss".
    let lower_text = text.to_lowercase();

    if lower_text.contains("rust") {
        println!("Case-insensitive match found");
    }
}

This works, but to_lowercase allocates memory. If you call this inside a tight loop processing thousands of lines, the allocation overhead adds up. The allocator has to find space, initialize it, and later free it. For performance-critical code, this is a bottleneck.

The standard tool for complex patterns and case-insensitive search without allocation is the regex crate. It compiles the pattern into a state machine. The search runs efficiently over the bytes.

use regex::Regex;

fn main() {
    // Regex::new returns a Result. It can fail if the pattern is invalid.
    // unwrap() panics on error. In production, handle the error or use
    // a compile-time regex macro.
    // (?i) enables case-insensitive matching.
    let re = Regex::new(r"(?i)rust").unwrap();

    let text = "Rust is fast";

    // is_match() returns bool. It does not allocate.
    if re.is_match(text) {
        println!("Match found");
    }
}

Convention aside: Pre-compile regexes. Compiling a regex is expensive. Never call Regex::new inside a loop. Create the regex once, store it, and reuse it. Use std::sync::LazyLock or once_cell for global regexes, or pass the compiled regex as an argument.

use regex::Regex;
use std::sync::LazyLock;

// LazyLock ensures the regex is compiled exactly once, on first access.
static RE_RUST: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(r"(?i)rust").unwrap()
});

/// Checks if text contains "rust" case-insensitively.
/// Uses a pre-compiled regex to avoid allocation and compilation cost.
fn contains_rust(text: &str) -> bool {
    RE_RUST.is_match(text)
}

Pitfalls and compiler errors

Case sensitivity is the most common trap. "Rust".contains("rust") returns false. If you expect case-insensitive behavior, you must handle it explicitly. The compiler will not warn you. The logic is simply wrong.

Allocation is the second trap. to_lowercase() returns a String. If you write text.to_lowercase().contains("term"), you allocate a new string on every call. Profile your code. If substring search appears in the flame graph, switch to regex or a custom iterator-based check.

Unicode normalization is a silent killer. contains matches bytes. If your data comes from user input or web sources, you might encounter composed versus decomposed forms. "é" can be a single codepoint (U+00E9) or "e" plus a combining accent (U+0065 U+0301). They look identical. contains sees different bytes. If you need normalization, use a crate like unicode-normalization to normalize the text before searching.

Compiler errors appear when you misuse types. If you try to pass an integer to contains, the compiler rejects it.

fn main() {
    let text = "hello";
    // E0277: the trait bound `i32: Pattern<&str>` is not satisfied.
    // contains expects a type that implements Pattern.
    // i32 does not implement Pattern.
    let _ = text.contains(42);
}

The error E0277 tells you exactly what's missing. The type you passed doesn't implement the Pattern trait. Fix it by passing a &str, char, or a type that implements Pattern.

Pre-compile your regex. Compiling inside a loop is a performance trap.

Decision matrix

Use contains for exact substring matches on &str or String. It allocates nothing and handles UTF-8 correctly.

Use to_lowercase().contains() for case-insensitive checks when performance is not critical and the string is small. The allocation cost is negligible for occasional checks.

Use the regex crate for case-insensitive search in performance-critical code, complex patterns, or when you need to avoid allocation. Pre-compile the regex to amortize the cost.

Use chars().any(|c| c == target) when you are searching for a single character and want to avoid the overhead of pattern matching, though contains is usually optimized enough for this.

Pick the tool that matches your constraint. Exact match? contains. Case-insensitive? regex or pay the allocation.

Where to go next