I keep running into the same bug pattern in real systems: a value starts life as text (a JSON field, an environment variable, a CSV column, a CLI flag), then someone treats it like a number, and the code “mostly works” until it hits an edge case—"12.50ms" instead of "12.50", a locale that uses commas, a value that overflows, or a stray trailing space that changes what you think you parsed.
Converting between std::string and float/double isn’t hard, but doing it predictably is a skill you build by knowing the trade-offs in the standard library: exception-based parsing (std::stof, std::stod), legacy C conversion (std::atof), stream formatting (std::stringstream), and modern, allocation-free parsing/formatting (std::fromchars, std::tochars).
In this guide, I’ll show you how I choose between these tools in 2026 codebases. You’ll get runnable examples, strict vs permissive parsing patterns, and the “gotchas” that cause production incidents: trailing junk, precision loss, rounding surprises, NaN/inf, and formatting that accidentally changes across machines.
Numbers-as-text: what you’re really converting
A string like "678.1234" looks like a number, but it’s really a sequence of characters with conventions layered on top: optional whitespace, an optional sign, digits, an optional decimal point, an optional exponent, and sometimes extras (units, separators, currency symbols). Conversion is the act of interpreting those characters according to a specific grammar.
Two practical points I want you to keep in mind:
1) float and double are binary floating-point types. Most decimal fractions cannot be represented exactly in binary. When you parse "0.1", you don’t store “one tenth”; you store the nearest representable binary float. This is normal, but it changes how you compare and print values.
2) You must decide whether you want strict parsing (reject anything that isn’t purely a number) or permissive parsing (accept the numeric prefix and ignore the rest). If you parse "12.5ms" as 12.5, that may be exactly what you want—or it may silently hide a data-quality problem.
Here’s the decision I recommend you make up front:
- If the string is supposed to be machine-produced numeric text (JSON, config, internal protocol), use strict parsing and fail loudly.
- If the string is human input (CLI, UI text fields), choose permissive parsing only when you also validate and communicate what you accepted.
String → float/double with std::stof / std::stod (simple, exception-based)
When you want the most straightforward “string to floating-point” conversion in C++, std::stof (to float) and std::stod (to double) are the classic choices. They accept a std::string (or something convertible) and produce a floating-point value.
What I like about them:
- They handle common formats: whitespace, sign, decimal point, exponent.
- They’re easy to read in code review.
What I watch out for:
- They throw exceptions on errors (
std::invalidargument,std::outof_range). If your parsing is in a hot loop, exceptions are the wrong control flow. - They can accept partial parses unless you check the “processed length” out-parameter.
If you want strict parsing with std::stod, always use the pos parameter and verify it consumed the entire string (after trimming if you allow surrounding whitespace).
#include <cctype>
#include <iostream>
#include <stdexcept>
#include <string>
static std::string trim(std::string s) {
auto is_space = [](unsigned char ch) { return std::isspace(ch) != 0; };
while (!s.empty() && isspace(staticcast<unsigned char>(s.front()))) {
s.erase(s.begin());
}
while (!s.empty() && isspace(staticcast<unsigned char>(s.back()))) {
s.pop_back();
}
return s;
}
int main() {
try {
std::string text = "678.1234";
text = trim(text);
std::size_t consumed = 0;
double value = std::stod(text, &consumed);
if (consumed != text.size()) {
throw std::invalid_argument("Trailing characters after number: ‘" + text.substr(consumed) + "‘");
}
std::cout << "Parsed double: " << value << "\n";
} catch (const std::exception& e) {
std::cerr << "Parse failed: " << e.what() << "\n";
return 1;
}
return 0;
}
If you truly don’t care about trailing junk (again: be deliberate), you can ignore consumed and accept prefix parsing. But I recommend you do that only for clearly human-facing input.
A quick rule I use:
std::stofis fine for UI-ish values where afloatis sufficient (graphics parameters, percentages).std::stodis my default for measurements, money-like quantities (still not “money safe”), and anything that will be stored or compared later.
C-style parsing with std::atof (permissive and quiet)
std::atof converts a C string (const char*) to a floating-point value. It’s widely available and extremely easy to call. It’s also the one I avoid in new code unless I’m interfacing with legacy C APIs.
The biggest practical issue: std::atof does not give you robust error reporting. If conversion fails, you get 0.0. That’s the same result you get for valid input like "0" or "0.0". In other words, you can’t distinguish failure from a legitimate zero without additional checks.
Here’s what std::atof looks like in a runnable example, plus a note about why it can mislead you:
#include <cstdlib>
#include <iostream>
int main() {
const char* ok = "678.1234";
const char* bad = "not-a-number";
double a = std::atof(ok);
double b = std::atof(bad); // returns 0.0, but so does "0"
std::cout << "atof(ok) = " << a << "\n";
std::cout << "atof(bad) = " << b << "\n";
return 0;
}
If you need a C API, I recommend std::strtod instead of std::atof because std::strtod lets you detect errors and see where parsing stopped.
My guidance in 2026:
- Don’t introduce
std::atofinto new C++ code when correctness matters. - Keep it only where you’re forced to accept “best effort” parsing and you already validate downstream.
Modern strict parsing without exceptions: std::from_chars
When I need speed, predictability, and no exceptions, I reach for std::from_chars. It parses directly from a character range and reports errors via a small result struct, not by throwing.
Why I like it for machine-oriented parsing:
- No heap allocation.
- No locale involvement (that’s a feature for config/protocol parsing).
- Clear error reporting with
std::errc. - Easy to enforce “consume all characters” rules.
In 2026 standard libraries, floating-point from_chars support is generally solid, but if you maintain portability to older libstdc++/libc++ builds, you should still keep an eye on your toolchain versions.
Here’s a strict parser that accepts optional surrounding whitespace but rejects trailing junk. I use std::expected for clean call sites (available in C++23 and commonly used in modern codebases).
#include <charconv>
#include <cctype>
#include <expected>
#include <iostream>
#include <string_view>
struct ParseError {
enum Code { Empty, Invalid, OutOfRange, Trailing } code;
std::size_t position = 0;
};
static std::stringview trimview(std::string_view s) {
auto is_space = [](unsigned char ch) { return std::isspace(ch) != 0; };
while (!s.empty() && isspace(staticcast<unsigned char>(s.front()))) {
s.remove_prefix(1);
}
while (!s.empty() && isspace(staticcast<unsigned char>(s.back()))) {
s.remove_suffix(1);
}
return s;
}
static std::expected<double, ParseError> parsedoublestrict(std::string_view text) {
text = trim_view(text);
if (text.empty()) {
return std::unexpected(ParseError{ParseError::Empty, 0});
}
double value = 0.0;
const char* begin = text.data();
const char* end = text.data() + text.size();
auto res = std::fromchars(begin, end, value, std::charsformat::general);
if (res.ec == std::errc::invalid_argument) {
return std::unexpected(ParseError{ParseError::Invalid, 0});
}
if (res.ec == std::errc::resultoutof_range) {
return std::unexpected(ParseError{ParseError::OutOfRange, 0});
}
if (res.ptr != end) {
std::sizet pos = staticcast<std::size_t>(res.ptr - begin);
return std::unexpected(ParseError{ParseError::Trailing, pos});
}
return value;
}
int main() {
for (std::string_view s : {" 1.25e3 ", "12.5ms", "nan", "", "1e9999"}) {
auto parsed = parsedoublestrict(s);
if (parsed) {
std::cout << "‘" << s << "‘ => " << *parsed << "\n";
} else {
std::cout << "‘" << s << "‘ failed (code=" << parsed.error().code
<< ", pos=" << parsed.error().position << ")\n";
}
}
}
Notice something important: whether "nan" is accepted can vary by library implementation details for from_chars with floats. If your protocol must allow NaN/inf, test it on your target toolchains and consider documenting the accepted grammar explicitly.
If you’re choosing a default approach for config/protocol parsing in 2026, I recommend:
std::from_charsfor strict, fast parsing.std::stodwhen you value readability and you’re already in exception-based code.
float/double → string: std::tostring, streams, and std::tochars
Going the other direction looks easy: “I have a double, I want a std::string.” The trap is that formatting is a policy decision. How many digits? Fixed or scientific? Should you preserve round-trip correctness (parse back to the same value) or print a human-friendly display?
std::to_string: convenient, but formatting is fixed
std::to_string is simple and always available (since C++11). It uses a default formatting that often prints about 6 digits after the decimal for floating types, which can surprise you with trailing zeros or slightly “off” values.
I use std::to_string mainly for:
- Debug logging where exact formatting doesn’t matter.
- Quick-and-dirty telemetry strings.
Runnable example:
#include <iostream>
#include <string>
int main() {
float f = 678.1234f;
double d = 678.1234;
std::string fs = std::to_string(f);
std::string ds = std::to_string(d);
std::cout << "to_string(float): " << fs << "\n";
std::cout << "to_string(double): " << ds << "\n";
}
std::stringstream / std::ostringstream: formatting control (human-friendly)
When output needs controlled formatting—say, “always two decimals” or “up to N significant digits”—streams are easy to read and work everywhere.
If you’re generating user-visible text (reports, UI labels) I still like this approach because it’s expressive:
#include <iostream>
#include <iomanip>
#include <sstream>
#include <string>
static std::string formatpricelike(double value) {
std::ostringstream out;
out << std::fixed << std::setprecision(2) << value;
return out.str();
}
static std::string format_measurement(double value) {
std::ostringstream out;
out << std::setprecision(6) << value; // significant digits by default
return out.str();
}
int main() {
std::cout << "Price-ish: " << formatpricelike(12.0) << "\n";
std::cout << "Measure: " << format_measurement(0.000123456789) << "\n";
}
Streams can be slower than to_chars and can be affected by locales if you imbue them. For UI output that’s fine; for high-throughput serialization, I choose differently.
std::to_chars: fast, no allocation (machine-friendly)
For performance-sensitive formatting or protocol output, std::tochars is the counterpart to fromchars. You provide a buffer, it writes digits, and you decide how to store them.
I like tochars because it’s explicit and predictable. If you want round-trip safety (print so that parsing back gives the exact same binary float), a common approach is to print with std::numericlimits<double>::max_digits10 significant digits.
#include <charconv>
#include <iostream>
#include <limits>
#include <string>
static std::string doubletostring_roundtrip(double value) {
char buffer[128];
auto res = std::to_chars(
buffer,
buffer + sizeof(buffer),
value,
std::chars_format::general,
std::numericlimits<double>::maxdigits10
);
if (res.ec != std::errc{}) {
return "<format-error>";
}
return std::string(buffer, res.ptr);
}
int main() {
double value = 0.1;
std::cout << "Round-trip text: " << doubletostring_roundtrip(value) << "\n";
}
If you’re thinking, “This is more code than std::to_string,” you’re right. The payoff is control and speed when it matters.
Choosing the right tool: my 2026 decision table
When teams ask me “Which one should we standardize on?”, I don’t answer with a shrug. I pick defaults based on input type and failure behavior.
Here’s a practical mapping I’ve used successfully:
My default choice
—
std::from_chars
std::stod / std::stof with pos check
std::strtod (not atof)
std::to_chars
std::ostringstream with setprecision
std::to_string
And here’s a “Traditional vs Modern” view that I’ve used in refactor proposals:
Traditional method
My recommendation
—
—
std::stod
std::fromchars Prefer fromchars in hot paths or strict parsing
std::ostringstream
std::tochars Prefer tochars for protocol/log volume; streams for UI
Exceptions or silent 0
std::expected/errc Prefer explicit errors for machine inputIf you’re building a library, you can expose both: a strict expected-returning API and a convenience throwing wrapper.
Precision, rounding, and the “why did my number change?” moment
If you’ve ever printed a parsed float and seen 678.123413 instead of 678.1234, you’ve hit the reality of binary floating-point.
What’s happening:
- The decimal text is interpreted as a real number.
- The machine stores the nearest representable binary float (
floathas about 7 decimal digits of precision;doubleabout 15–17). - When you print it, you’re seeing a decimal approximation of that stored binary value.
Three practical recommendations I give teams:
1) Prefer double unless you have a real memory/bandwidth reason to use float. In most back-end and tooling code, double is the safer default.
2) When comparing values, avoid == for computed floating-point results. Use an absolute/relative tolerance.
3) Decide your formatting policy explicitly:
- For round-trip safe serialization, use
maxdigits10withtocharsorostringstream. - For UI text, format for humans: fixed decimals, rounding rules, and sometimes locale-aware formatting (but keep that separate from protocol serialization).
Here’s a tiny example that demonstrates why “printing more digits” changes what you see:
#include <iostream>
#include <iomanip>
int main() {
double x = 0.1;
std::cout << "Default: " << x << "\n";
std::cout << "17 digits: " << std::setprecision(17) << x << "\n";
std::cout << "Fixed (6 dp): " << std::fixed << std::setprecision(6) << x << "\n";
}
You should treat “conversion” as part of your correctness story, not as a throwaway detail.
Edge cases that bite: whitespace, trailing units, locale, NaN/inf
This is where bugs hide—because basic demos rarely cover them.
Trailing characters ("12.5ms")
If you accept prefix parsing, you may accidentally accept bad data. The strict pos check (stod) or ptr != end check (from_chars) is what keeps you honest.
If you truly want to accept "12.5ms", don’t just parse the prefix and ignore the rest. Parse and validate the unit, too:
- If suffix is "ms", store milliseconds.
- If suffix is "s", multiply.
- Otherwise reject.
That prevents “12.5m” from silently becoming “12.5”.
Locale differences
Stream-based parsing/formatting and some conversion routines can be influenced by locale (decimal separators, thousands separators). That’s great for UI text and terrible for protocol text.
My rule:
- Machine formats should be locale-neutral (dot decimal). Use
fromchars/tochars. - Human formats can be locale-aware, but isolate that formatting to the UI/reporting layer.
NaN and infinities
Text like "nan", "inf", or "-inf" shows up in scientific and telemetry domains. Decide if you allow it.
- If you allow it, test your library behavior and document it.
- If you disallow it, explicitly reject it before parsing or after parsing (using
std::isfinite).
Overflow and underflow
"1e9999" doesn’t fit in a double. That’s not “invalid text”, it’s out of range. Good code distinguishes those failure modes.
With std::stod, you’ll typically see std::outofrange. With std::fromchars, you’ll see std::errc::resultoutofrange.
Empty strings and whitespace-only strings
These are almost always data bugs. Reject them early and report a useful error.
Common mistakes I see in code reviews (and what I do instead)
Here are the patterns I flag most often, along with the concrete fix.
1) Mistake: std::atof everywhere
- Problem: silent failures become zeros.
- Fix: use
std::from_chars(strict, no exceptions) orstd::stodwithpos(simple).
2) Mistake: std::stod(text) without checking consumption
- Problem: "12.5ms" becomes 12.5 and nobody notices.
- Fix: use the
posparameter and reject trailing non-space.
3) Mistake: std::to_string(value) for serialized output
- Problem: output precision is not a contract; round-tripping may fail or drift.
- Fix: use
std::tocharswithmaxdigits10for machine output.
4) Mistake: comparing parsed floats with ==
- Problem: parsing and arithmetic introduce tiny representation differences.
- Fix: compare with tolerance and write tests that reflect your tolerance.
5) Mistake: mixing UI formatting with protocol formatting
- Problem: locale or human-friendly rounding leaks into storage/logs.
- Fix: separate “presentation formatting” from “serialization formatting.”
If you want one practical workflow improvement in 2026: add fuzz tests for your parsing functions. It’s amazing how quickly fuzzing finds the weird strings your users (or integrations) will generate.
Practical next steps you can apply today (without changing your whole codebase)
If you’re maintaining an existing C++ codebase, you don’t have to refactor everything at once. Here’s the upgrade path I recommend and have used successfully:
1) Pick a strict parsing helper for machine input.
- If you can use C++23: adopt an
std::expected<double, Error> parsedoublestrict(...)wrapper aroundstd::from_chars. - If you’re on C++17/20: return
bool+ out-parameter, or returnstd::optional<double>plus a separate error log path.
2) Replace silent conversions first.
- Anywhere you see
std::atof, replace it in the paths that affect correctness (billing rules, limits, safety thresholds, config parsing).
3) Standardize formatting for machine output.
- Define one function to format
doublefor storage/logging/protocol with round-trip safety. - Keep UI formatting separate and explicit.
4) Lock it down with tests.
- Add tests for: whitespace, trailing junk, exponent notation, overflow, underflow,
-0, and the exact formatting policy you choose.
If you want a single “default” rule to remember: I parse machine input with std::fromchars and I serialize machine output with std::tochars; I reach for streams when I’m speaking to humans. That small discipline prevents a surprising number of bugs—and it makes your code’s intent obvious to the next person who has to maintain it.



