1. Why the choice of exception type matters
When a beginner first learns about throw, a tempting thought appears: “I'll just throw the string "Error" and be done with it.” The problem is that in a couple of weeks you'll be reading your own code and thinking: “Is this a format error? An out-of-bounds? An external problem? A contract violation?” The exception type is like a label on a box.
A well-chosen standard exception type solves two problems at once: first, it allows the handler (code in the catch) to pick the correct reaction without trying to parse the message text. Second, it disciplines the function author: if you throw std::invalid_argument, you explicitly acknowledge that the input data do not meet the contract and you are not going to “guess what the user meant.”
To avoid turning the program into an absurd play, let's stick to a simple idea: the exception type should reflect the category of the problem, and the message should give context. This is far more useful than a bare "Runtime error" without details.
A short note: the C++ standard describes exception handling mechanics in detail (see specification sections about exception handling like [except.handle]).
Where these exceptions live and how to include them
When you see std::runtime_error, std::invalid_argument or std::out_of_range, the almost-always-correct header is <stdexcept>. Sometimes a beginner tries to include <exception> and wonders why the compiler complains. That's normal: <exception> provides base things like std::exception, while the "ready-made types" that take a message live in <stdexcept>.
These classes are nice because they already have a "pocket" for text: you pass a string to the constructor and print e.what() in the catch. It's important not to confuse this with "printing the error where it happened": usually a low-level function should not decide how to present the error to the user. It should report “what happened” via an exception, and the decision about the reaction should be left to the upper level (often main).
Minimal example of "correct inclusion":
#include <iostream>
#include <stdexcept>
int main() {
try {
throw std::runtime_error("something went wrong");
} catch (const std::exception& e) {
std::cout << e.what() << '\n'; // something went wrong
}
}
2. std::runtime_error: "operation failed at runtime"
std::runtime_error sounds like “anything goes,” and that is partly true: it is a fairly universal category for problems that arose because of runtime conditions, not because the programmer violated a function's contract. Conceptually it's closer to “the world turned out different than expected” — and the operation cannot complete correctly.
Imagine a real-life analogy: you went to a store with a correct shopping list (arguments are valid), but the store is suddenly closed (an external condition). This is not std::invalid_argument, because you did everything right; it's more like std::runtime_error, because the action cannot be performed.
In real programs std::runtime_error is often used for errors like “failed to perform operation,” “failed to open resource,” “failed to read data,” or “system state does not allow continuation.” We won't dig into files and system error codes now, but the idea of runtime_error is something you need.
Simple example: "operation not possible right now"
Let's make a small function that by contract should "do the job" but sometimes cannot.
#include <stdexcept>
#include <string>
void do_important_thing(bool ok) {
if (!ok) throw std::runtime_error("operation failed: service unavailable");
}
And handling:
#include <exception>
#include <iostream>
int main() {
try {
do_important_thing(false);
} catch (const std::exception& e) {
std::cout << "Error: " << e.what() << '\n';
// Error: operation failed: service unavailable
}
}
Note: the message contains both "what we were doing" and "why it failed." This small habit later saves hours of debugging.
3. std::invalid_argument: "arguments violate the contract"
std::invalid_argument is your way to say: “I cannot continue because the input data do not make sense for this operation.” An important nuance: this is not necessarily about a "bad user." Often "bad" arguments arise from a logic error in the program because one function handed another incorrect data.
This exception is especially useful when you write functions that must protect their invariants. For example, a function set_age(int age) might require age >= 0. If -5 is passed, you usually cannot continue: the object's state becomes meaningless. Here std::invalid_argument is an honest choice.
Example: argument check
#include <stdexcept>
int days_in_week(int weekIndex) {
if (weekIndex < 0) throw std::invalid_argument("weekIndex must be >= 0");
return 7;
}
Why is this std::invalid_argument and not std::runtime_error? Because the problem is with the argument: the function is not obliged to guess what you meant, nor to "fix" the input.
A very common source of invalid_argument: std::stoi
If you've already experimented with std::stoi, you may have seen two classic scenarios: the string does not look like a number, or the number is too large. The first scenario is typically associated with std::invalid_argument.
We won't dive into stoi details or its standardized behavior now, but the logic for us is simple: if the string is "not a number", then it is an "invalid argument for conversion."
Sketch of a "string → int" function that gives a clear error:
#include <stdexcept>
#include <string>
int parse_int(const std::string& s) {
if (s.empty()) throw std::invalid_argument("empty string is not a number");
return std::stoi(s); // may throw
}
Here we already add our own semantic piece: an empty string is definitely not a number, and that's clearer than "something inside stoi."
4. std::out_of_range: "went beyond allowed bounds"
std::out_of_range is the exception about bounds: indices, ranges, limits. It's very nice to use where the error has a clear form: “a value exists, but it is outside the allowed range.” This is not necessarily an "argument meaningless in itself," as with std::invalid_argument. It can be a perfectly valid number but too large, or an index that does not fit the collection.
The most classic meeting place is checked access to containers. You already know that std::vector has operator[] (unchecked) and at() (checked). If the index is bad, at() typically reports this by throwing std::out_of_range.
Example: vector.at() and a clear failure
#include <iostream>
#include <stdexcept>
#include <vector>
int main() {
std::vector<int> v{10, 20, 30};
try {
std::cout << v.at(5) << '\n';
} catch (const std::out_of_range& e) {
std::cout << "out_of_range: " << e.what() << '\n';
}
}
This example is important psychologically: at() is not "slow and sad," but "honest and safe when you are unsure about indexes."
Another frequent source of out_of_range: too large a number
Even if a string looks like a number, it may not fit into an int. In that case the error is not "not a number" but that the number is too large for the chosen type. By meaning, this is a pure std::out_of_range: we exceeded the range of int.
5. How to choose and how to catch these exceptions
Quick cheat sheet for choosing
Now it's important to form a simple "map" in your head. You don't need to become an exception philosopher. You need a practical cheat sheet that works in 95% of cases.
Below is a table that helps you quickly decide:
| Exception type | What it's about | Typical phrase in your head | Example situations |
|---|---|---|---|
|
argument is meaningless for the operation | “you must not call this function like this” | empty string instead of a number, negative age |
|
value/index outside the range | “value too large/small for allowed bounds” | v.at(i) with bad i, number doesn't fit in int |
|
operation failed due to runtime conditions | “I did everything right, but the world didn't allow it” | external resource unavailable, operation cannot complete |
If you want a slightly more "technical" view, remember that "contract" exceptions (like std::invalid_argument and std::out_of_range) are good signals for precondition violations, while std::runtime_error is for "runtime problems."
Mini exception hierarchy and catch order
When you write several catch blocks, remember the rule: the first matching handler is chosen. Therefore, you usually put more specific types above and more general ones below. This relates to inheritance: many exceptions inherit from std::exception, and if you catch std::exception too early, the specific types will never be reached.
Let's illustrate it simply:
flowchart TD
A[std::exception] --> B[std::runtime_error]
A --> C[std::logic_error]
C --> D[std::invalid_argument]
C --> E[std::out_of_range]
The fact that the standard defines a unified mechanism for type-based exception handling is part of the overall model.
6. Mini-application: "List of numbers" with clear errors
So this topic doesn't remain "theory about scary words," let's continue the "one evolving application" line. We'll make a mini-CLI that stores numbers in a std::vector<int> and accepts commands as lines.
Commands will be: add a number, show a number by index, compute the sum. And importantly — in three places we'll get three different categories of errors and learn to express them with different exceptions.
Application data and command "skeleton"
Let's start with the simplest: we have std::vector<int> data; and a loop reading lines. We'll parse using std::stringstream.
#include <iostream>
#include <sstream>
#include <string>
#include <vector>
int main() {
std::vector<int> data;
std::string line;
while (std::getline(std::cin, line)) {
std::cout << "cmd> " << line << '\n'; // echo for clarity
}
}
This is still an "empty terminal," but already a skeleton where we'll insert error handling.
Command push: catching invalid_argument and out_of_range from parsing
Let's make a function parse_int_or_throw that turns a string into an int, and if it cannot, throws a clear exception. It's convenient to show both cases here: "not a number" (std::invalid_argument) and "doesn't fit" (std::out_of_range).
#include <stdexcept>
#include <string>
int parse_int_or_throw(const std::string& s) {
if (s.empty()) throw std::invalid_argument("number is missing");
return std::stoi(s); // may throw invalid_argument/out_of_range
}
Now the command handler:
#include <ssstream>
#include <string>
#include <vector>
void cmd_push(std::vector<int>& data, const std::string& arg) {
int x = parse_int_or_throw(arg);
data.push_back(x);
}
Note: we do not catch exceptions here. We let them propagate up — to where we have the "user-facing point."
Command get: throwing out_of_range for a bad index
Here we have a direct candidate: the index. So by meaning it's std::out_of_range. We intentionally use at() so the program does not silently corrupt memory but honestly says "bad index."
#include <stdexcept>
#include <vector>
int cmd_get(const std::vector<int>& data, int index) {
return data.at(index); // will throw out_of_range on bad index
}
Where runtime_error is useful in our mini-CLI
In our example std::runtime_error is convenient for "operation impossible due to state." For example, the sum command may require that the list be non-empty.
#include <numeric>
#include <stdexcept>
#include <vector>
int cmd_sum(const std::vector<int>& data) {
if (data.empty()) throw std::runtime_error("sum failed: list is empty");
return std::accumulate(data.begin(), data.end(), 0);
}
This is not std::invalid_argument, because the function arguments are correct (the vector exists), but the operation by our application rules "does not make sense" in the current state.
Main try/catch in main: handler order
Now let's put everything together: parse the command, call the needed function, and catch errors in one place. Here it's important to show the order: first std::out_of_range, then std::invalid_argument, then the general std::exception. This way messages will be more specific.
#include <exception>
#include <iostream>
#include <sstream>
#include <stdexcept>
#include <string>
#include <vector>
int main() {
std::vector<int> data;
std::string line;
while (std::getline(std::cin, line)) {
try {
std::stringstream ss(line);
std::string cmd, arg;
ss >> cmd >> arg;
if (cmd == "push") cmd_push(data, arg);
else if (cmd == "get") std::cout << cmd_get(data, parse_int_or_throw(arg)) << '\n';
else if (cmd == "sum") std::cout << cmd_sum(data) << '\n';
else throw std::invalid_argument("unknown command: " + cmd);
} catch (const std::out_of_range& e) {
std::cout << "Range error: " << e.what() << '\n';
} catch (const std::invalid_argument& e) {
std::cout << "Bad input: " << e.what() << '\n';
} catch (const std::exception& e) {
std::cout << "Error: " << e.what() << '\n';
}
}
}
Here it's important to notice several things at once. We use std::invalid_argument both for "not a number" and for "unknown command" — because these are all input data errors. We catch std::out_of_range separately because it's a frequent and clear class of errors. And std::exception remains the "safety net."
7. Typical mistakes
Mistake #1: using a single std::runtime_error "for everything."
This indeed makes the code "compile quickly," but you lose the meaning of types. In a month you'll catch runtime_error and not know whether it's bad input, an out-of-bounds, or a real runtime problem. A minimal discipline is to separate invalid_argument (bad input) and out_of_range (bad bounds) from "other" runtime problems.
Mistake #2: placing catch (const std::exception&) above catch (const std::out_of_range&).
This breaks the idea of specific handling: the general handler will catch everything first, and the narrow catch will never run. The usual symptom is: you expect to catch out_of_range, but you always end up in the general std::exception. The cure is simple: order your catch blocks from specific to general.
Mistake #3: catching exceptions by value (catch (std::exception e)).
It looks harmless, but it may lead to extra copies and to "slicing" (loss of information about the real exception type) if you catch the base type by value. The basic habit is to catch (const std::exception& e) and the same for derived types.
Mistake #4: making an empty catch and "continuing like nothing happened."
An empty handler is a way to hide a problem rather than solve it. Sometimes it's used as a "hack" so the program doesn't crash, but this is often worse than crashing: the application's state can become inconsistent and the user will get strange results. The minimum a catch should do is report the error or explicitly abort the current operation.
Mistake #5: mixing "user message" and "developer message" in one text.
If you write throw std::invalid_argument("Bad input at parse_int_or_throw(): token='...' line=..."), it may be useful in early stages, but over time messages become noise. It's better to keep the message short and informative: what exactly is wrong and which key parameter is responsible. When you have logs and diagnostic levels, you can make messages more verbose, but not today.
GO TO FULL VERSION