A fast, allocation-light, glibc-style printf for Java 8+.
Compile once, format many times. fast-printf trades a one-time parsing cost for a tight, type-specialised
formatting loop. The 1.2.13 mixed-log benchmark measured 1.8–6.8× faster than String.format()
(depending on JDK) and ~50% less allocation per call. See Performance for workload and version details. Designed for hot paths — high-throughput logging, text-protocol serialization, real-time systems —
where String.format() shows up in a profile or GC trace.
- Quick Start
- Why fast-printf?
- Performance
- Installation
- Usage
- Format String Reference
- How It Works
- Differences from
String.format() - Contributing
- License
Add the dependency (Maven):
<dependency>
<groupId>io.github.yuyuzha0</groupId>
<artifactId>fast-printf</artifactId>
<version>1.2.14</version>
</dependency>Compile once, format many times:
import io.fastprintf.FastPrintf;
private static final FastPrintf F = FastPrintf.compile("User %s (id=%d) scored %.2f");
String s = F.format("Alice", 42, 99.5);
// → "User Alice (id=42) scored 99.50"That's the whole API for typical use. The rest of this document covers the no-boxing Args builder, the format-string
syntax, and the design trade-offs.
- 🚀 Faster than
String.format()on every JDK we measured — ~6.8× on JDK 8, ~5× on JDK 11, ~4.5× on JDK 17, ~1.8–2.2× on JDK 21 (whose rewrittenFormatterclosed most of the gap). See Performance for the full chart and table. - 🗑️ ~50% less allocation than
String.formaton a typical log-line — a rope-like internal data structure avoids intermediate strings andchar[]copies, cutting young-gen GC pressure in hot loops. - ⚙️ Glibc-compatible syntax. Follows the familiar C / C++
printfrules rather thanjava.util.Formatter's Java-specific quirks (%Supper-cases the string,%pprints object identity, etc.). - 💡 Modern float formatting on every JDK. Backports the Schubfach engine from OpenJDK 21, so
double/floatoutput is shortest-correctly-rounded even on Java 8. - ⛓️ No-boxing fluent API for primitive-heavy call sites:
Args.create().putInt(...).putDouble(...)skips theInteger.valueOf/Double.valueOfallocations that varargs forces. - 🧩 Zero runtime dependencies. Compatible with Java 8 and newer.
Reach for it when you have a hot String.format call site — high-throughput logging, text-protocol serialization
(CSV / log lines / metrics), or any latency-sensitive code where formatting shows up in a profile or GC trace.
For everyday formatting where the bytes-per-second don't matter, the standard String.format() is fine.
Version 1.2.14 consolidates numeric layout, resolves dynamic fields once, and avoids uppercasing complete numeric ropes. Changes are evaluated with isolated JMH workloads and the mixed-log benchmark. Correctness fixes are listed in the changelog.
The following cross-JDK tables describe 1.2.13, not the current release. They are retained as historical measurements and should not be compared directly with results from a different run.
Benchmarked with CommonUsageBenchmark (JMH 1.37, @Fork(2), Corretto on a single M-series box). The format string
[%s] %s id=%d latency=%.3fms is a realistic log-line case mixing literal text with %s, %d, and %.Nf; the same
string is fed to both FastPrintf.compile(...) and String.format(...) — no per-side translation or workarounds.
| Path | JDK 8 | JDK 11 | JDK 17 | JDK 21 |
|---|---|---|---|---|
fastPrintf (varargs) |
~214 ns | ~216 ns | ~200 ns | ~221 ns |
fastPrintf (Args builder, no-boxing) |
~326 ns | ~251 ns | ~247 ns | ~247 ns |
fastPrintf (with ThreadLocal cache) |
~244 ns | ~199 ns | ~197 ns | ~186 ns |
String.format |
~1450 ns | ~1069 ns | ~886 ns | ~404 ns |
Speedup — varargs vs String.format |
~6.77× | ~4.95× | ~4.43× | ~1.83× |
Speedup — TL cache vs String.format |
~5.94× | ~5.38× | ~4.50× | ~2.17× |
These results describe this workload and JVM configuration. They do not establish the cause of cross-JDK differences or predict every application's behavior.
Time isn't the whole story. Allocation bytes-per-op, measured with -prof gc:
| Path | Time | Allocation |
|---|---|---|
fastPrintf (varargs) |
~221 ns | 696 B/op |
fastPrintf (Args builder, no-boxing) |
~247 ns | 592 B/op |
fastPrintf (with ThreadLocal cache) |
~186 ns | 608 B/op |
String.format |
~404 ns | 1280 B/op |
fast-printf allocates roughly half the bytes String.format does on this workload — and on JDK 21, where the
nanoseconds gap is narrowest, the GC-pressure gap is what carries the win in production code.
The Args builder (no-boxing). Avoids primitive boxing, but still constructs argument traits.
Its allocation and latency trade-offs depend on the workload and JVM; lower B/op alone does not
establish better p99 latency.
enableThreadLocalCache(). Reuses the builder's backing buffer across calls. It supports
recursive calls from object formatting and releases its in-use state even if formatting fails.
Reuse can help in tight loops, but performance depends on memory locality. See
ComplexFormatLocalityBenchmark and benchmark your own workload before enabling it.
Maven:
<dependency>
<groupId>io.github.yuyuzha0</groupId>
<artifactId>fast-printf</artifactId>
<version>1.2.14</version>
</dependency>Gradle:
implementation 'io.github.yuyuzha0:fast-printf:1.2.14'The core idea is to compile a format string once into a FastPrintf instance and reuse it for all subsequent formatting
operations. Instances are immutable and thread-safe.
import io.fastprintf.Args;
import io.fastprintf.FastPrintf;
public class Example {
// Compile once and reuse. The FastPrintf instance is immutable and thread-safe.
private static final FastPrintf FORMATTER =
FastPrintf.compile("User %s (id=%d) scored %.2f");
public static void main(String[] args) {
// 1. Using varargs — simple and convenient
String r1 = FORMATTER.format("Alice", 42, 99.5);
System.out.println(r1);
// → User Alice (id=42) scored 99.50
// 2. Using the fluent Args builder — no primitive boxing
Args primitiveArgs = Args.create()
.putString("Alice")
.putInt(42)
.putDouble(99.5);
String r2 = FORMATTER.format(primitiveArgs);
System.out.println(r2);
// → User Alice (id=42) scored 99.50
}
}For richer formatting — uppercase strings (%S), zero-padding (%05d), hex (%#08X), date/time (%t{...}), etc. —
see the Format String Reference below.
| Style | Boxing? | Optimises for | When to use |
|---|---|---|---|
FORMATTER.format(123, "test") (varargs) |
Yes (primitives) | CPU throughput | Most call sites; readability wins. |
Args.of(123, "test") |
Yes (primitives) | CPU throughput | Same as varargs; useful when you build args incrementally. |
Args.create().putInt(123).putString("test") |
No | Allocation rate / GC | Hot serialization or logging paths where young-gen pressure / p99 latency dominates. |
All three produce identical output. The choice is CPU throughput vs allocation rate, not "good vs better": the no-boxing builder allocates ~15% less but costs ~18 ns of method-dispatch per call (see Design notes on the optional paths). Pick based on which axis your workload is actually bound on.
Applications using Jackson 2 can pass JsonNode arguments directly. Declare
com.fasterxml.jackson.core:jackson-databind in your application, using your Jackson BOM or dependency
management for its version. The integration is built and tested with Jackson 2.20.1; Jackson 3 uses
different packages and is not detected by this adapter.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.fastprintf.FastPrintf;
ObjectMapper mapper = new ObjectMapper();
JsonNode order = mapper.readTree("{\"name\":\"Alice\",\"amount\":12.3}");
FastPrintf format = FastPrintf.compile("%s: %.2f"); // Cache and reuse.
String result = format.format(order.get("name"), order.get("amount"));
// Alice: 12.30Availability is checked once per library class loader when the generic-object path is first used.
When Jackson is available, JsonNode arguments use io.fastprintf.jackson.JsonNodeTraits; otherwise ordinary objects
continue to use ObjectTraits. Primitive and existing built-in argument paths do not probe Jackson.
The optional Maven dependency is not inherited by applications, and core formatting works without
Jackson. On the module path, requires static com.fasterxml.jackson.databind makes Jackson optional
at runtime; an application using the integration should declare requires com.fasterxml.jackson.databind.
| JSON node | Formatting behavior |
|---|---|
| Text | %s uses unquoted text; %c uses its first character |
| Number | Numeric specifiers use its value; BigInteger/BigDecimal nodes retain their precision |
| Boolean | %s produces true or false; numeric conversion is rejected |
| Null | Uses the existing NullTraits, just like Java null; %s/%p produce null, numeric conversion is rejected |
| Object / array | %s produces JSON text; numeric conversion is rejected |
| Missing node | Rejected, so a missing field is not silently treated as zero or null |
Numeric strings are not coerced to numbers. Floating-to-integer conversions truncate; dynamic width
and precision use Jackson's intValue() conversion. %p retains a non-null node's original identity.
Java null still uses the usual null-argument handling. If using get(), validate required fields
yourself; use path() to obtain a missing node for absent fields. Nodes are not copied, so do not
mutate a tree concurrently with formatting. For exact parsed decimals, configure Jackson to create
BigDecimal nodes; the adapter cannot recover precision already lost while parsing into double nodes.
JsonNode also implements Iterable: Args.of(node) selects the existing iterable overload and
expands its children. To pass one node, use Args.create().put(node), Args.of((Object) node), or
pass it directly to formatter.format(node) as in the example above.
Use an application-side serializer with Jackson's @JsonSerialize. This example caches a compiled
format and emits a JSON string, leaving quoting and escaping to Jackson:
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import io.fastprintf.FastPrintf;
import java.io.IOException;
import java.math.BigDecimal;
public class JacksonExample {
public static class Price {
@JsonSerialize(using = AmountSerializer.class)
public BigDecimal amount = new BigDecimal("12.3");
}
public static class AmountSerializer extends JsonSerializer<BigDecimal> {
private static final FastPrintf FORMAT = FastPrintf.compile("%.2f");
@Override
public void serialize(BigDecimal value, JsonGenerator out, SerializerProvider provider)
throws IOException {
out.writeString(FORMAT.format(value));
}
}
public static void main(String[] args) throws IOException {
System.out.println(new ObjectMapper().writeValueAsString(new Price()));
// {"amount":"12.30"}
}
}Jackson handles null properties normally. Do not use writeRawValue for printf output: padding,
prefixes and arbitrary text need not be valid JSON numbers. This example only customizes
serialization; it does not change @JsonFormat or provide reverse parsing. If an application needs
an annotation carrying a pattern, implement Jackson's
ContextualSerializer
and compile the pattern when creating the per-property serializer, rather than on every write.
A FormatTraits instance is a value-bearing formatting argument adapter. Its methods expose the
representations supported by that value: integer, floating point, text, character, date/time and
object identity. Here “traits” means those formatting capabilities; it is not a Java language
feature or a mixin. Args selects the adapter before rendering. The existing name is retained for
API compatibility; ArgumentAdapter would also describe its role.
Format string syntax:
%[flags][width][.precision]specifier[{date-time-pattern}]
The %t / %T specifiers accept an inline DateTimeFormatter pattern.
- Syntax:
%t{pattern} - Example:
%t{yyyy-MM-dd'T'HH:mm:ss.SSSZ} - Default: If no pattern is provided (
%t), an appropriate ISO formatter is chosen based on the argument type (e.g.ISO_OFFSET_DATE_TIMEfor aZonedDateTime).
| Specifier | Output | Example |
|---|---|---|
d or i |
Signed decimal integer | 392 |
u |
Unsigned decimal integer | 7235 |
o |
Unsigned octal | 610 |
x |
Unsigned hexadecimal integer (lowercase) | 7fa |
X |
Unsigned hexadecimal integer (uppercase) | 7FA |
f / F |
Decimal floating point | 392.65 |
e |
Scientific notation (lowercase e) |
3.9265e+2 |
E |
Scientific notation (uppercase E) |
3.9265E+2 |
g / G |
Shortest representation of %e or %f |
392.65 |
a / A |
Hexadecimal floating point (lowercase/uppercase p) |
-0xc.90fep-2 |
c |
Character | a |
s |
String of characters (from Object.toString()) |
sample |
S |
String of characters, converted to uppercase | SAMPLE |
t / T |
Date/Time string (case affects final string output) | 2023-12-31T23:59:59+01:00 |
p |
Object "pointer" (class name + identity hash). Throws an exception for primitive types. | java.lang.Integer@707f7052 |
n |
Nothing printed. The argument is consumed. | |
% |
A literal % character |
% |
| Flag | Description |
|---|---|
- |
Left-aligns the result within the field width. |
+ |
Forces the result to be prefixed with a sign (+ or -), even for positive numbers. Overrides the space flag. |
(space) |
Prefixes positive numbers with a space. Ignored if the + flag is present. |
# |
Alternate form: • o → prefixes with 0• x / X → prefixes with 0x / 0X• f, e, g → forces a decimal point• g / G → prevents stripping of trailing zeros |
0 |
Pads the output with leading zeros (instead of spaces) to meet the specified width. Ignored if - is present or if precision is specified for an integer. |
| Field | Description |
|---|---|
width |
Minimum characters to print. Padded with spaces (or zeros with 0 flag). Never truncates. * reads width from the next int argument. |
.precision |
For each type: • Integers — minimum number of digits (zero-padded) • Floats ( f, e) — digits after the decimal point• Floats ( g) — max significant digits• Strings ( s, S) — max characters to print.* reads precision from the next int argument. |
The performance of fast-printf comes from four architectural pillars:
- Ahead-of-time compiler.
FastPrintf.compile()parses the format string once into a list of optimisedAppenderobjects. Parsing never re-runs. - Zero-copy string building. An internal rope-like
Seqdata structure concatenates formatted parts with lightweight wrappers instead of copying characters. The finalStringis rendered in a single pass. - Ahead-of-time argument processing. The
Argsobject converts arguments into a list ofFormatTraits— specialised, type-aware handlers. This eliminatesinstanceofchecks and reflection from the formatting loop. - Backported float engine. Incorporates OpenJDK 21's
DoubleToDecimal(the "Schubfach" algorithm) sodouble/floatoutput is correctly rounded and shortest-possible on every supported JDK.
String.format() on JDKs prior to 18 has known issues converting double / float to decimal: the output is not
always the shortest, correctly-rounded representation, which can introduce subtle accuracy bugs in scientific or
financial code. fast-printf backports the modern Schubfach-based engine from OpenJDK 21, so that correctness
guarantee — and the performance that comes with it — is available even on Java 8.
fast-printf intentionally differs from Java's String.format to align with glibc conventions and to keep the
formatting loop tight:
- Glibc vs Java
Formatterconventions. Followsglibcprintf. For example,%Supper-cases the string — unlike Java's behaviour, which is tied toFormattable. %p(pointer) specifier. Provides the C-style%pspecifier to print an object's identity. Not available inString.format(). The implementation is type-safe and throws on a primitive argument, preventing auto-boxing bugs.- No argument indexing.
%2$sand friends are not supported; arguments are consumed sequentially for performance. - No locale support. Formatting is locale-agnostic for performance (
.is always the decimal separator).
Use JDK 21 for the default development build; CI also verifies JDK 8, 11, and 17.
mvn -B verify -Dgpg.skip=trueUse the manual Performance benchmarks workflow to collect JMH timing and allocation results
on JDK 8 and 21. It uploads the raw JSON as workflow artifacts.
The JDK 21 build enforces JaCoCo line, branch, and instruction coverage floors based on the pre-1.2.14 baseline. Codecov requires 95% project and patch coverage. Add regression tests for correctness fixes and compare JMH latency and allocation before retaining performance changes. Prefer shared rules and clear code; special-case fast paths need substantial measured gains.
After merging, run bash create_tag.sh v1.2.14 from a clean, synchronized main, then publish a
GitHub Release for that tag. The release workflow validates the Maven version, verifies and signs
the artifacts, and publishes to Maven Central. Pushing the tag alone does not publish artifacts.
Found a bug or have an idea? File it at the issue tracker. Pull requests welcome.
fast-printf is licensed under the GNU General Public License v2 with Classpath Exception, the same license used by
the OpenJDK.
This choice of license is deliberate, as this library includes internal utility classes that are derivative works of OpenJDK (specifically for high-fidelity floating-point formatting). These backported files retain their original copyright headers and are governed by the terms of the GPLv2+CE, and thus the library as a whole adopts this license.