Skip to content

PHP's sprintf pads by bytes, so one † moved my table columns

Posted in Php, Cli, Debugging

By Dušan Dželebdžić

Photo by Declan Sun on Unsplash
Photo by Declan Sun on Unsplash

I have a small PHP CLI script that pulls my logged hours out of a client's YouTrack and prints a billing report. Plain text, fixed-width columns, sprintf all the way down. It runs in a Docker container on PHP 8.5.4 and it's about as glamorous as it sounds.

The contract with that client has a weekly minimum charge, a bit like a retainer, so one section lists the weeks where I billed less than that. Rows can carry a marker: * for a week that spans a month boundary, for a week that's still in progress. I don't normally work weekends, but this one went to a big migration for a client. On Saturday I checked on my billing, and the report had one of each:

Week                         Billed        Deficit       Time needed
--------------------------------------------------------------------------------
2026-08-31 - 2026-09-06 * $1,400.00 $350.00 2h
2026-09-14 - 2026-09-20 † $787.50 $962.50 5h 30m

The second row is shifted two columns to the left. Not one, not a tab's worth. Exactly two.

Same format string, different result

Both rows come out of the same line of code:

echo sprintf("%-28s $%-12s $%-12s %s\n",
$label,
number_format($wd['total'], 2),
number_format($wd['deficit'], 2),
formatHM($minutesNeeded)
);

%-28s means left-justify and pad to 28. Both labels are 25 characters long: two dates, a separator, a space, a marker. Same format, same length, and one row is two spaces short.

The only difference between the rows is the marker. That was the whole investigation.

sprintf counts bytes

The dagger is U+2020. In UTF-8 that's three bytes:

echo strlen('†');     // 3
echo mb_strlen('†'); // 1
echo bin2hex('†'); // e280a0

PHP strings are byte arrays, and sprintf measures them the way strlen does. So for the dagger row it sees 27 bytes and adds one space to reach 28. My terminal then draws those three bytes as a single column, and the row ends up 26 columns wide instead of 28. The asterisk row is 25 bytes and 25 columns, so it gets its three spaces and lines up fine.

The manual describes the width as a minimum number of characters. For a PHP string, "character" means byte.

str_pad is no help here, because it counts the same way:

echo str_pad('2026-09-14 - 2026-09-20 †', 28), "|\n";
// 2026-09-14 - 2026-09-20 † |

What I enjoyed about this one is how long it had been sitting there. The script had been printing that table for months. The asterisk is ASCII, so it never triggered anything. And the dagger row only appears when the current week is under the minimum and it's already Saturday or Sunday, because that's when the script starts reporting an unfinished week. I rarely run a billing report on a weekend, so the bug was only visible on the two days of the week when nobody was looking.

The fix

PHP 8.3 added mb_str_pad(), which pads by characters. sprintf has no multibyte mode, so the padding moves out of the format string:

// mb_str_pad: sprintf pads by bytes, which misaligns the multibyte † marker
echo sprintf("%s $%-12s $%-12s %s\n",
mb_str_pad($label, 28),
number_format($wd['total'], 2),
number_format($wd['deficit'], 2),
formatHM($minutesNeeded)
);
2026-08-31 - 2026-09-06 *    $1,400.00     $350.00       2h
2026-09-14 - 2026-09-20 † $787.50 $962.50 5h 30m

If you're stuck below 8.3, give str_pad the difference between bytes and characters as extra width:

echo str_pad($label, 28 + strlen($label) - mb_strlen($label));

Same output. Both need the mbstring extension, which you almost certainly have.

Characters aren't columns either

mb_str_pad fixed my table because a dagger is one code point and one terminal column. That's not true of everything you might print. I ran a few strings through the same PHP 8.5.4 container:

strlen  mb_strlen  mb_strwidth  grapheme_strlen  columns  string
3 1 1 1 1 †
9 3 6 3 6 日本語
3 1 2 1 2 ✅
4 1 2 1 2 🚀
3 2 2 1 1 e + U+0301 (é)

CJK characters and most emoji are one code point each and two columns wide. Pad 日本語 to 10 with mb_str_pad and you get a 13-column cell, which is the original bug with the sign flipped. For those you want mb_strwidth() and your own padding:

echo $label . str_repeat(' ', max(0, 10 - mb_strwidth($label)));

Then there's the last row: an e followed by a combining acute accent. (The strings are in the last column because I'd like this table to stay aligned in your browser.) Two code points, one column, and mb_strwidth says 2. grapheme_strlen from intl gets that one right and gets the CJK row wrong. No single function in PHP answers "how many columns will my terminal use for this", and terminals don't fully agree with each other anyway. If the text is yours, keep the markers to things that are one code point and one column, and mb_str_pad is enough.

The precision side of sprintf has the same byte problem, and it's worse than a crooked table:

$s = sprintf('%.2s', 'a†');
echo bin2hex($s); // 61e2
var_dump(mb_check_encoding($s, 'UTF-8')); // false

%.2s keeps two bytes, which is the a plus the first third of the dagger. That's not valid UTF-8 anymore. Print it and you get a replacement glyph. Hand it to json_encode and you get false and a malformed UTF-8 error. Padding by bytes makes your output ugly. Truncating by bytes makes it invalid. Use mb_substr or mb_strimwidth before the string goes anywhere near a %.Ns.

Takeaway

In PHP, every width and precision in a sprintf format string is a byte count. With ASCII that's the same thing as characters and columns, which is why this can sit in a script for months. The day a , an é or a shows up in a padded field, the columns move, and the format string looks as correct as it did the day before. Pad with mb_str_pad, truncate with mb_substr, and be suspicious of any marker you pasted in from a character picker.


Got a PHP codebase doing something weird? Send me the details and I'll take a look.