A surprisingly common question
You've got a chunk of text. Maybe it came from a file. Maybe it's the body of an HTTP response. Maybe a user pasted it into your CLI. You want to do something with each line: print it, parse it, count it, transform it. The first instinct from Python or JavaScript is text.split('\n'). That works in Rust too, but the language has nicer ways, and the right choice depends on where the string came from and what "line" means to you.
The headline answer: a String or &str already in memory? Use .lines(). Streaming from a file or the network? Wrap it in a BufReader and use the lines() from BufRead. Both spellings give you back lines without the trailing newline characters. The differences matter, and we'll walk through them.
When the whole string is already in memory
This is the simplest case. You have the text as a &str or String. Just call .lines():
fn main() {
let text = "first line\nsecond line\nthird line";
// .lines() returns an iterator that yields &str slices, one per line.
// Newlines are stripped from each item: you get "first line", not
// "first line\n". Both \n and \r\n are recognised as line breaks.
for line in text.lines() {
println!("got: {line}");
}
}
Output:
got: first line
got: second line
got: third line
A few things worth knowing about .lines(). It's lazy, so iterating over a 10 MB string doesn't allocate anything; each &str it yields is a borrowed slice into the original. It strips both \n and \r\n, which matters when your input came from a Windows tool or a network protocol that uses CRLF. And if the final line has no trailing newline, you still get it as the last item, just like every other line.
Don't confuse .lines() with .split('\n'). They're close but not the same:
let weird = "a\nb\n";
let via_lines: Vec<&str> = weird.lines().collect();
// ["a", "b"]
let via_split: Vec<&str> = weird.split('\n').collect();
// ["a", "b", ""] ← the trailing newline produces an empty trailing element
split is mechanical: it cuts on the literal character. lines is semantic: it understands what humans mean by "lines of text." Pick lines when that's what you want.
When the text is coming from a file
Files might be huge. Loading the whole thing into memory before you process it is wasteful and sometimes impossible. The standard idiom is BufReader plus BufRead::lines:
use std::fs::File;
use std::io::{self, BufRead};
fn main() -> io::Result<()> {
// File::open returns an unbuffered handle. Reading from it directly would
// make a syscall for every byte.
let file = File::open("notes.txt")?;
// BufReader wraps the file with an 8 KiB buffer. Now reads happen in
// chunks, and we can call line-oriented helpers on it.
let reader = io::BufReader::new(file);
// .lines() here is the BufRead::lines method. It yields Result<String>
// because each line is read from disk and decoded as UTF-8 into an owned
// String. Disk reads can fail; non-UTF-8 bytes can fail.
for line in reader.lines() {
let line = line?;
println!("{line}");
}
Ok(())
}
The two lines() look similar but produce different things. The &str::lines from earlier yields borrowed slices. The BufRead::lines yields owned Strings wrapped in Result, because each line has to be allocated and any read can fail. When you copy patterns from one to the other, watch the type.
If your file isn't UTF-8 or you're reading from a network socket where the encoding is uncertain, switch to read_until(b'\n', &mut buf) and handle the bytes yourself. BufRead::lines will return an error on the first non-UTF-8 byte and stop.
Filtering, mapping, and combining
lines() is an iterator, which means the entire Iterator toolkit is available. Skip blanks, count comments, parse numbers:
fn main() {
let cfg = "# settings file
host=example.com
port=8080
# comment after a blank line
debug=true
";
// Filter out empties and comments, then split each remaining line
// on '=' into a (key, value) pair. The trim() handles trailing \r if any.
let pairs: Vec<(&str, &str)> = cfg
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.filter_map(|l| l.split_once('='))
.collect();
for (k, v) in &pairs {
println!("{k} -> {v}");
}
}
split_once('=') returns Option<(&str, &str)>. filter_map keeps the Some cases and drops the Nones, so a malformed line without an = is silently ignored. Whether you want that ignoring or a hard error is a judgment call; if you want errors, use .map(parse) and collect into Result<Vec<_>, _>.
Reading lines without the iterator
Sometimes you want a manual loop, often because you need to break early or you're reading until a sentinel. BufRead::read_line fills a string in place:
use std::io::{self, BufRead};
fn main() -> io::Result<()> {
let stdin = io::stdin();
let mut handle = stdin.lock();
let mut buf = String::new();
loop {
buf.clear();
// read_line keeps the trailing newline. It returns the number of
// bytes read, including the newline. 0 means EOF.
let n = handle.read_line(&mut buf)?;
if n == 0 { break; }
// strip the trailing \n (and \r if present) for processing.
let line = buf.trim_end_matches(&['\n', '\r'][..]);
if line == "quit" { break; }
println!("you said: {line}");
}
Ok(())
}
Note we reuse the String buffer across iterations. That's a quiet performance win: no allocation per line. The downside is the trailing newline, which read_line keeps for you. Strip it if you don't want it.
Common pitfalls
Reading lines from a String like it's a file. You don't need BufReader for an in-memory string. Just call .lines() directly on the &str. Wrapping a String in a BufReader works (because &[u8] implements Read), but it's an extra layer for nothing.
Forgetting that BufRead::lines returns Result<String>. The compiler will catch this:
error[E0277]: `Result<String, std::io::Error>` doesn't implement `Display`
--> src/main.rs:9:21
|
9 | println!("{}", line);
| ^^^^ the trait bound `Result<String, ...>: Display` is not satisfied
You forgot to ? or .unwrap() the line first.
CRLF on Windows. Files saved on Windows often use \r\n. lines() handles this: both &str::lines and BufRead::lines strip the \r for you. But if you go down to read_until or split('\n'), the \r will still be there and you'll get subtle bugs comparing strings.
Files that don't end in a newline. Both lines methods give you the last line with no trailing newline as the final item. You don't need to special-case it. The classic Unix tools used to misbehave here; Rust does the right thing.
Holding lots of references to a giant string. Every &str from .lines() borrows from the original String. You can't drop the original until all the slices are gone. If you need lines that outlive their source, allocate owned Strings with .lines().map(String::from).collect::<Vec<_>>().
When to reach for what
In-memory text? &str::lines. Files and network streams? BufRead::lines on a BufReader. Need to handle encoding errors gracefully or peek ahead? Drop to read_until. Need to process gigabytes a second? Look at the bstr crate, which adds more efficient byte-oriented line iteration that avoids the UTF-8 check.
For very long-running stdin loops, lock stdin once with io::stdin().lock() (as in the manual loop above). The unlocked handle re-acquires the global lock on every read, which adds up.