Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild
Back to Blog Explainer

printf in JavaScript: Template Literals, Intl, and padStart

Sean

Platform Writer

Aug 13, 2026
8 min read

JavaScript has no printf and no String.format. The replacements are template literals for interpolation, padStart/padEnd for width, toFixed for decimal places, and Intl.NumberFormat for anything a user reads. console.log does accept %s and %d format specifiers, which is the one place printf-style syntax genuinely exists — and it works differently from what you would expect.

printf in JavaScript: Template Literals, Intl, and padStart

The absence is deliberate rather than an oversight, and the pieces that replaced it handle cases printf never could.

Table of contents

Template literals cover interpolation

const name = 'Ada';
const count = 3;

`Hello ${name}, you have ${count} messages`

// Expressions, not just variables
`Total: ${(price * qty).toFixed(2)}`
`${count} item${count === 1 ? '' : 's'}`

// Multi-line, no \n needed
const query = `
  SELECT id, name
  FROM users
  WHERE active = true
`;

This replaces the interpolation half of printf entirely, and it is better in the ways that matter: the value appears where it is used rather than in an argument list, so there is no way to get the order wrong or miscount the placeholders.

The classic printf bug — arguments not matching the format string — is not expressible here, which is a real safety improvement even if it feels like a small one.

Width and padding

// printf("%5d", 42)  ->  "   42"
String(42).padStart(5)          // "   42"

// printf("%-5d|", 42)  ->  "42   |"
String(42).padEnd(5) + '|'      // "42   |"

// printf("%05d", 42)  ->  "00042"
String(42).padStart(5, '0')     // "00042"

// Any pad string, not just spaces and zeros
'3'.padStart(8, '.-')           // ".-.-.-.3"

// A fixed-width table
const rows = [['api', 'ok'], ['worker', 'down']];
for (const [svc, status] of rows) {
  console.log(`${svc.padEnd(12)}${status}`);
}

padStart and padEnd operate on strings, so numbers need String() or a template literal first. They pad to a total length rather than adding N characters, which is the same semantics as printf’s width field.

One caveat that printf shared: padding counts UTF-16 code units, so emoji and combining characters do not align the way you expect. For a terminal table containing arbitrary user text, a width-aware library is the honest answer.

Numbers

// printf("%.2f", 3.14159)
(3.14159).toFixed(2)            // "3.14"

// printf("%.3e", 123456)
(123456).toExponential(3)       // "1.235e+5"

// Significant digits
(123.456).toPrecision(4)        // "123.5"

// printf("%x", 255)
(255).toString(16)              // "ff"
(255).toString(2)               // "11111111"

// Hex with width, the printf("%04x") equivalent
(255).toString(16).padStart(4, '0')   // "00ff"

toFixed returns a string, and it rounds — occasionally not the way you expect, because it inherits floating-point representation. (1.005).toFixed(2) gives "1.00", because 1.005 is not exactly 1.005 in binary. This is not a JavaScript quirk; printf does the same thing in C.

For money, do not use floats at all. Store minor units as integers and format at the edge, or use a decimal library. The formatting is the easy part; the arithmetic is where the error accumulates.

Intl, which printf never had

For anything a human reads, Intl is the correct tool and it handles cases no format string ever did.

// Thousands separators, locale-aware
new Intl.NumberFormat('en-GB').format(1234567.891)
// "1,234,567.891"
new Intl.NumberFormat('de-DE').format(1234567.891)
// "1.234.567,891"

// Currency -- symbol placement and decimals per locale
new Intl.NumberFormat('en-GB', {
  style: 'currency', currency: 'GBP',
}).format(1234.5)               // "£1,234.50"

// Percent
new Intl.NumberFormat('en-GB', { style: 'percent' }).format(0.256)  // "26%"

// Compact
new Intl.NumberFormat('en', { notation: 'compact' }).format(1234567) // "1.2M"

// Data sizes
new Intl.NumberFormat('en', {
  style: 'unit', unit: 'megabyte', unitDisplay: 'narrow',
}).format(512)                  // "512MB"

// Relative time
new Intl.RelativeTimeFormat('en', { numeric: 'auto' }).format(-1, 'day')
// "yesterday"

// Lists
new Intl.ListFormat('en', { style: 'long', type: 'conjunction' })
  .format(['api', 'worker', 'db'])   // "api, worker, and db"

Decimal separators differ by locale, currency symbols go on different sides, and “yesterday” is not something you can express with %s. Hand-rolling any of this produces output that is subtly wrong for most of the world.

Construct the formatter once and reuse it. Creating an Intl.NumberFormat is relatively expensive, and doing it inside a render loop is a measurable cost.

const gbp = new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' });
rows.forEach(r => console.log(gbp.format(r.total)));

console.log format specifiers

The one place printf-style syntax genuinely exists in JavaScript, and it behaves differently from what most people assume.

console.log('%s has %d messages', 'Ada', 3);
// Ada has 3 messages

console.log('%o', { a: 1 });      // object, expandable in devtools
console.log('%c styled', 'color: red; font-weight: bold');

// %d truncates rather than rounding
console.log('%d', 3.7);           // 3

These are a console feature, not a string feature — they do not exist on String and cannot be used to build a value. The %c specifier applies CSS in browser devtools, which is genuinely useful for making one log line stand out.

Node’s util.format exposes the same behaviour as a function you can call, which is the closest thing to a real sprintf in the standard library:

import { format } from 'node:util';
const s = format('%s has %d messages', 'Ada', 3);

Tagged templates, and structured logging

function pad(strings, ...values) {
  return strings.reduce((out, str, i) => {
    const v = values[i] !== undefined ? String(values[i]).padEnd(10) : '';
    return out + str + v;
  }, '');
}

pad`${'api'}${'running'}`;   // "api       running   "

// The important use: escaping
function html(strings, ...values) {
  return strings.reduce((out, str, i) => {
    const v = values[i] != null ? escapeHtml(String(values[i])) : '';
    return out + str + v;
  }, '');
}

Tagged templates let you intercept interpolation, which is how libraries provide SQL parameterisation and HTML escaping that are safe by default. That is a capability printf never had and it is worth more than the formatting.

One place to resist formatting altogether: application logs. A carefully padded log line is pleasant to read and painful to query. Log structured objects and let the log viewer format them.

// Hard to query
console.log(`user ${id} failed after ${ms}ms`);

// Queryable
logger.info({ userId: id, durationMs: ms }, 'request failed');

The second form lets you filter on durationMs > 1000 rather than parsing it back out of a sentence. That matters most when you are looking at runtime logs during an incident — searching structured fields against the deploy that introduced the problem is a different experience from grepping formatted text, and it is why per-deploy runtime logs are worth having as fields rather than lines.

How this fits the rest of the stack

Template literals for interpolation, padStart and padEnd for width, toFixed and toString(16) for numeric formats, and Intl for anything a user sees. console.log('%s %d', ...) and Node’s util.format are the only real printf-style syntax, and they are console features rather than string ones.

Reuse Intl formatters rather than constructing them in a loop, and log structured objects rather than formatted sentences so the fields stay queryable. If you are working out what the service producing those logs costs to run, the RunxBuild hosting calculator lists service, database, storage, and bandwidth separately.

Useful related references:

FAQ

Does JavaScript have a printf function?

No, and it has no String.format either. Template literals handle interpolation, padStart and padEnd handle width, toFixed handles decimals, and Intl handles locale-aware formatting. Node’s util.format is the closest equivalent to sprintf in the standard library.

How do I pad a number with leading zeros in JavaScript?

Convert to a string and use padStart: String(42).padStart(5, '0') gives "00042", matching printf’s %05d. padStart pads to a total length rather than adding a fixed number of characters, which is the same semantics as printf’s width field.

How do I format a number with thousands separators?

new Intl.NumberFormat('en-GB').format(1234567). This respects locale conventions — German uses dots for thousands and a comma for the decimal point. Construct the formatter once and reuse it, since creating one is relatively expensive inside a loop.

Why does console.log(‘%s’, x) work if JavaScript has no printf?

Format specifiers are a console feature rather than a string feature, implemented by browser devtools and Node. They only work as arguments to console methods and cannot build a string value. Node exposes the same behaviour as a callable function through util.format.

How do I format currency in JavaScript?

Use Intl.NumberFormat with style: 'currency' and a currency code. It places the symbol correctly for the locale and applies the right number of decimal places. Do the arithmetic in integer minor units rather than floats, since formatting cannot recover precision already lost.

#javascript#printf#string formatting#template literals#intl