nanoprintf: a printf that fits in 480 bytes and never calls libc
The smallest public printf implementation for its feature set.
At a glance
- What is it?
- nanoprintf is a single header C implementation of snprintf and vsnprintf for firmware, with no memory allocation, under 100 bytes of stack and a configuration matrix that trades features for object code size. The author is blunt that the code is optimised for size rather than readability.
- Who is it for?
- nanoprintf fits firmware and bootloader work where stdio's file descriptor layer is dead weight and a kilobyte of flash is the difference, and it is easy to drop in because it is one header and one define. It does not fit application code that wants readable formatting logic, since the author states the implementation is #ifdef soup, and it does not fit work that needs locale aware conversions.
- Can I use it commercially?
- Check first. The repository uses a licence we do not classify automatically, so read its LICENSE file before any commercial use.
- Is it still maintained?
- Yes. The repository last received commits 31 days ago.
- What is it written in?
- Mainly C++, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 25, 2026, and from our analysis. They are not legal advice.
Editorial analysis
A formatting library with no call into libc
nanoprintf is an implementation of snprintf and vsnprintf for embedded systems, and its defining characteristic is what it refuses to do. It makes no memory allocations and uses less than 100 bytes of stack. It does include C standard headers, but only to get C99 types and argument lists, and no calls are made into stdlib or libc, with the exception of any internal large integer arithmetic your compiler might emit.
That is the whole argument for its existence. In firmware work the author wants stdio's string formatting without the syscall or file descriptor layer requirements, because in tiny systems those are almost never needed when the goal is logging into small buffers or emitting directly to a bus.
The language level is deliberately conservative. The code is written in a minimal dialect of C99 for maximal compiler compatibility, compiles cleanly at the highest warning levels on clang, gcc and msvc, and raises no issues from UBsan or Asan. The README states it is exhaustively tested on 32-bit and 64-bit architectures. When fully enabled it aims for C11 standard compliance, with one stated exception: locale conversions that require wcrtomb to exist. C23 binary integer output is optionally supported as described in proposal N2630.
Some Windows specific headers are needed when compiling natively for msvc, which is the one platform concession in an otherwise portable header.
Two macros and a single header
nanoprintf is a single header file in the style of the stb libraries. The README is explicit that the rest of the repository is tests and scaffolding and is not required for use, so the adoption surface is one file copied into your tree.
The usage pattern is the same one the stb libraries use. You add the implementation to exactly one of your source files:
// define your nanoprintf configuration macros here (see "Configuration" below)
#define NANOPRINTF_IMPLEMENTATION
#include "path/to/nanoprintf.h"Every other file that wants to call the functions simply includes the header without the define, so the implementation is compiled once and the declarations are visible everywhere. Configuration macros are set next to the implementation define, which means the feature set is decided at one place in the build rather than through a build system flag.
The library is statically configurable, and the README frames the trade directly: users find a balance between size, compiler requirements and feature set. Floating-point conversion, the large length modifiers and size write-back are all configurable and are only compiled if explicitly requested.
That last point is the mechanism the rest of the project depends on. A firmware image does not pay for a float formatter it never calls, and the size table in the README is really a map of the switches you can turn.
npf_pprintf hands every character to your callback
There are two ways to get output, and the examples in the README show both in one snippet. The buffer form is snprintf with a npf_ prefix. The streaming form is npf_pprintf, which takes a putc callback and a context pointer:
#include "nanoprintf.h"
static void my_uart_putc(int c, void *ctx) {
uart_write((my_uart_t *)ctx, c);
}
void print_to_uart(void) {
my_uart_t *uart = get_my_uart();
npf_pprintf(&my_uart_putc, uart, "Hello %s%c %d %u %f\n", "worl", 'd', 1, 2, 3.f);
}
void print_to_buf(void *buf, unsigned len) {
npf_snprintf(buf, len, "Hello %s", "world");
}The callback contract is spelled out: my_uart_putc is an npf_putc callback, and nanoprintf calls it once for each output character and passes the same context pointer each time. That is the whole interface for streaming output, and it is why the library needs no file descriptor layer. Your UART driver, your bus, your buffer, your choice of transport.
The repository ships three examples that build on this: use_npf_directly, wrap_npf and wrap_npf_float, with the README pointing at the first two for more details.
The npf_ prefix is what keeps the header from colliding with the libc names it is standing in for, which is what makes it a drop-in without shadowing snprintf in a codebase that also links a real libc.
The size table is the product, and it is generated
The README's size section is a generated table, produced by tests/size_report.py, and it measures the total size in bytes of the nanoprintf text symbols compiled with arm-none-eabi-gcc -Os.
The numbers tell the story. The minimal configuration is 480 bytes on both Cortex-M0 and Cortex-M4. Adding binary output takes it to 540 and 532 bytes. Field width support moves it to 920 and 868 bytes, field width plus precision to 1136 and 1116. The floating-point table grows from there: %f alone is 1736 bytes on Cortex-M4 and 1456 without precision support, and the row for %f %e %g %a reaches 2868 bytes, or 2448 with precision compiled out. The everything row, which adds large, binary and write-back, sits at 3436 bytes on Cortex-M4.
Two design points explain the layout. Every row from field width down shares one base, consisting of field widths, small length modifiers and the # flag, with everything else off, and the field width row is also the floating-point table's zero. And precision gets its own columns rather than its own rows, because a floating-point build does not require it.
That last point is a real trade-off and the README explains it directly: compiled out, each conversion runs at the default precision C gives it, and a precision in the format string makes the conversion fail to parse and print verbatim. If you enable %f and leave precision out, your format string silently changes meaning rather than erroring loudly.
Overflow behaviour is a configuration choice, not an accident
The library's answer to buffer overflow is configurable, and the README calls these safety extensions for snprintf and vsnprintf. They can be set to return a trimmed string or a fully empty string when an overflow event occurs.
That matters more than it first appears. The alternative to a printf that truncates predictably is one that writes past the end of your buffer, which on a microcontroller is a memory corruption bug with no debugger attached. Having the truncated output rather than an empty string keeps log lines readable, while the empty-string mode is the stricter choice for code where a partial string might be mistaken for a complete one.
The compliance framing is the other side of this. The project aims for C11 standard compliance when fully enabled, and the one documented exception is locale conversions that require wcrtomb to exist. If your build needs locale aware number conversion, this library does not provide it, and no configuration switch changes that, because wcrtomb is exactly the kind of libc dependency the design removes.
C23 binary integer output is supported optionally, following N2630, and it appears in the size table as its own row rather than being bundled into the base. Binary output is a real need in firmware work, for bit fields and packed protocol headers, but it is not something most hosted code ever asks for, so it stays optional here.
It can parse a format string without formatting anything
Beyond formatting, nanoprintf can parse printf-style format strings to extract the various parameters and conversion specifiers, without doing any actual text formatting.
This is the feature with the least obvious use and the most potential. A format string is a specification, and a specification can be inspected rather than executed. A tool that can enumerate the conversion specifiers in a format string can tell you how many arguments a call site expects, and which specifiers are present, without running the format. That is the basis for argument-count checking at compile time, for wrapper generators that adapt a user supplied format, and for diagnostics that point at a mismatched specifier.
None of those consumers are built here. The README states the capability and stops, and the repository ships a conformance document, CONFORMANCE.md, plus a tests/ directory and a build.py and build.bat pair for the test harness. So the parsing side is available to you as a library function, and what you build on top of it is your own work.
That is also the honest framing of the project as a whole. nanoprintf is a component with a small, well-defined job, and the interesting engineering decisions have been made about footprint and portability rather than about ergonomics.
The author calls it #ifdef soup and explains why
The Philosophy section is unusually candid for a library README. The code is optimised for size, not readability or structure. Modularity and cleanliness add overhead at this scale, so most of the functionality and logic is pushed together into npf_vpprintf. The author writes that this is not what normal embedded systems code should look like, that it is #ifdef soup and hard to make sense of, and apologises in advance if you have to spelunk around in the implementation.
The stated alternative offered in the same document is that you may simply be a significantly better programmer, in which case contributions are welcome.
The motivation section gives the original goal: a single-file public-domain drop-in printf that came in under 1KB in the minimal configuration for bootloaders, and under 3KB with the floating-point extras enabled. The measured table in the README matches that intent. The author also notes that many embedded stdio implementations are larger or slower than they need to be, which matters for bootloader work, and that if you do not need the syscalls or the stdio extras, you can use nanoprintf together with nosys.specs to slim down the build.
So the trade is stated by the author rather than discovered by the reader: smallest possible code, at the cost of internal structure you are not meant to modify casually.
Licence, build system and how the tests are wired
The licence picture has two badges in the README, one for public domain and one for 0BSD, and the repository is classified with a custom licence that GitHub cannot categorise. The LICENSE file at the root of the repository is the authoritative text, and both of those terms are permissive, so the practical difference between them is small. Read the file for the exact wording, and this is not legal advice.
Maintenance has been steady. The tagged releases are v0.8.1 on 2026-09-25, v0.8.0 on 2026-08-10 and v0.7.0 on 2026-07-24, and the last push was on 2026-09-25 with the repository not archived. The release numbers being below 1.0 with monthly tags is consistent with a library whose interface is still settling.
The build system is unusual and worth understanding before you try to run the tests. The top level Makefile is POSIX, takes its compiler from CC and CXX, defaults to a Release configuration and a 64-bit architecture, and has a SHARD variable described as a flag to build only shard I of N flag combinations, which lets continuous integration spread the conformance matrix over parallel jobs. Dependencies are managed by a tool called envy, invoked through shims in bin/, and the Makefile explains that supplying DOCTEST_H opts out of envy entirely, so a fresh clone can build with the doctest header and Python alone.
Around that sit the usual artefacts of a careful project: CONFORMANCE.md, CONTRIBUTORS.md, CLAUDE.md, a .clangd configuration, an envy.lua, a ruff.toml, and a release.py for cutting releases.
Editorial conclusion
nanoprintf fits firmware and bootloader work where stdio's file descriptor layer is dead weight and a kilobyte of flash is the difference, and it is easy to drop in because it is one header and one define. It does not fit application code that wants readable formatting logic, since the author states the implementation is #ifdef soup, and it does not fit work that needs locale aware conversions. Before adopting it, compile the configuration you intend and read the generated size table for your architecture, because the numbers in the README are for arm-none-eabi-gcc -Os on Cortex-M0 and Cortex-M4.
Frequently asked questions
What is nanoprintf and how small is it?
It is a single header C implementation of snprintf and vsnprintf for embedded systems, with no memory allocation and under 100 bytes of stack. Compiled with arm-none-eabi-gcc -Os, the minimal configuration measures 480 bytes of object code on Cortex-M0 and Cortex-M4.
How do I add nanoprintf to a project?
Define NANOPRINTF_IMPLEMENTATION and include nanoprintf.h in one source file to compile the implementation, then include the header normally in every other file that calls the npf_ functions. The rest of the repository is tests and scaffolding and is not needed for use.
Does nanoprintf call into the C library?
No. It includes C standard headers only for C99 types and argument lists, and makes no calls into stdlib or libc, except any internal large integer arithmetic the compiler itself might emit. When fully enabled it aims for C11 compliance, with locale conversions needing wcrtomb as the stated exception.
What happens if the output does not fit in the buffer?
Safety extensions can be configured so that snprintf and vsnprintf return either a trimmed string or a fully empty string on a buffer overflow event, instead of writing past the end of the buffer.
Can nanoprintf parse a format string without formatting it?
Yes. It can parse printf-style format strings to extract the parameters and conversion specifiers without performing any actual text formatting, which lets you build tooling on top of format specifications.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/charlesnicholson-nanoprintf)Was this useful?