std::string offers two ways to read a character at a position: s[i] and s.at(i). They look interchangeable, and for a valid index they are. The difference shows up at exactly the moment you care most — when i is out of range. operator[] produces undefined behaviour; at() throws std::out_of_range. The choice between the two is one of the cleanest examples in C++ of trading raw speed for a defined failure mode, and knowing when each is appropriate is a small habit worth forming.
Three things to take away:
s[i]is unchecked; an out-of-range index is undefined behaviour, with one quirky exception worth knowing.s.at(i)is bounds-checked and throwsstd::out_of_rangeifi >= size().- Use
[]when the index is provably valid (loop bounds, computed offsets); useat()when the index comes from outside or when you want a defined failure if your invariants ever break.
The two access methods
The standard reference describes both functions in one sentence each. For at(): “Returns the character at position n. If n >= size(), out_of_range is thrown.” For operator[]: “Returns the character at position pos. If pos == size(), the return value is charT(), that is, a null character. The behavior is undefined if pos > size().”
In code:
#include <iostream>
#include <stdexcept>
#include <string>
int main(){
std::string s{ "hello" };
char a = s[1]; // 'e' — unchecked
char b = s.at(1); // 'e' — checked, but valid
char c = s[10]; // undefined behaviour: nothing thrown,
// c may be garbage, the program may crash
// immediately, or it may seem to work fine.
try {
char d = s.at(10); // throws std::out_of_range
} catch (const std::out_of_range& e) {
std::cerr << "bad index: " << e.what() << '\n';
}
}
For the in-range reads, the two are equivalent. For the out-of-range reads, operator[] is silently broken — the compiler emits a memory access at an offset past the string’s buffer, and whatever is there gets returned (or the program faults, or the optimiser deletes surrounding code on the assumption that the access is valid). at() produces a defined exception that the caller can handle.
The s[size()] quirk
There is one corner case worth knowing because it sometimes surprises people. operator[] is defined to return a null character when the index is exactly size() — not just “happens to,” but specified by the standard. This is the same null terminator that makes c_str() work (Nibble #060), and operator[] lets you read it without it being undefined behaviour:
std::string s{ "hi" };
char term = s[s.size()]; // OK: returns '\0'
char bad = s[s.size() + 1]; // undefined behaviour
at() does not extend this courtesy — s.at(s.size()) throws, because the bounds-check uses n >= size(). If you ever need to peek at the null terminator without writing c_str(), only operator[] will do it. In practice this is a niche use; the takeaway is simply to know the asymmetry exists.
Why two access methods exist
The cost of the bounds-check is one comparison and one branch per access. For a single read, this is invisible. Inside a hot loop iterating millions of characters, the branch can measurably hurt performance — and often the loop’s bounds are already provably correct, so the check is genuinely redundant:
for (std::size_t i = 0; i < s.size(); ++i) {
process(s[i]); // [] is fine: i < size() by construction
}
The standard’s design was that operator[] is the fast path for code that has already proved the index is in range, and at() is the safe path for everything else. Putting the bounds-check into operator[] would have made every random-access read pay for a check most of them don’t need; leaving it out of at() would have removed the safe option entirely. Two methods is the trade.
A useful rule: if you can answer the question “why is this index in range?” without thinking, use operator[]. If the answer involves any uncertainty — the index came from a parser, a user, a network, or a calculation that might overflow — use at().
The same pattern across containers
at() is not a std::string quirk; it is a general convention the standard library applies everywhere indexed access exists:
| Container | [] behaviour on bad index | at() behaviour |
|---|---|---|
std::string | undefined behaviour | throws out_of_range |
std::vector | undefined behaviour | throws out_of_range |
std::array | undefined behaviour | throws out_of_range |
std::deque | undefined behaviour | throws out_of_range |
The mental model is the same in every case. [] is the fast, unchecked accessor; at() is the bounds-checked accessor. Once you’ve internalised the rule for string, it carries over to every other random-access container in the standard library — and to your own containers, if you follow the same convention. (std::map::at() follows the same idea applied to keys: it throws when the key is absent, instead of inserting like operator[] does.)
A pragmatic rule
For application code where performance is rarely the dominant concern, defaulting to at() is reasonable. An out-of-range bug becomes a thrown exception with a useful message instead of a silent memory corruption that surfaces later. For performance-sensitive code, default to operator[] and reserve at() for the access points where the index is genuinely external — input parsing, deserialisation, indexed lookup from user data.
The wrong default is the one many beginners learn: operator[] everywhere, including on indices the program received from outside. That is the configuration where the language stops helping you, and a single bad input becomes undefined behaviour rather than a recoverable error.
Takeaway
operator[] is the unchecked accessor; at() is the bounds-checked one. Pick the one that matches your invariants: [] when the index is provably valid by construction, at() when it isn’t. The decision is the same for string, vector, array, and deque, so the habit transfers across every container in the standard library. The rule is simple: if you can’t answer “why is this index in range?” in a sentence, you want at() — and an out_of_range you can catch is always better than undefined behaviour you can’t.