Skip to content

Rust Coreutils vs GNU: The Differences That Break Bash Scripts on Ubuntu 26.04

ubuntucoreutilsuutilsexit-codestroubleshooting
8 min read

Quick Answer

Ubuntu 26.04 runs most coreutils commands from uutils, the Rust rewrite, and in uutils 0.10.0 some behaviour scripts depend on is different from GNU coreutils 9.7. Run side by side in an Ubuntu 26.04.1 container: timeout -s KILL exits 124 instead of 137. mkdir, ln, chmod and touch print different error text, and ln says Already exists instead of File exists, so a grep for the old message stops matching. wc -m counts characters, not bytes, under the C locale. numfmt exits 0 on a value GNU rejects. base64 -d prints nothing before an invalid byte where GNU printed the valid part. pr -t lays out columns differently. tail -n1 file >> file never stops on a file over 4 KB. Most commands, including sort, cut, stat and date, matched in every test. coreutils-diff.sh runs any command line under both implementations and reports differences in exit status, stdout, stderr and files written.

First: which implementation is each command?

Ubuntu Rust Coreutils: Check and Switch Back lists which commands are Rust and which are still GNU on your box, and has the apt line that switches the whole set back.

A backup job that checks if [[ $rc -eq 137 ]] to tell a hard kill from a soft timeout reports "timed out" for every kill after the upgrade, and nothing errors. That is the shape of most Rust coreutils breakage on Ubuntu 26.04: the command succeeds or fails as before, and one number or one line of text is different.

What Is Actually Different?

Every row below was run on 2026-10-10 in an ubuntu:26.04 container on this machine: Ubuntu 26.04.1 LTS, uutils coreutils 0.10.0 (rust-coreutils 0.10.0-1ubuntu2~26.04.1) against GNU coreutils 9.7 (gnu-coreutils 9.7-3ubuntu2.1, which 26.04 keeps installed as gnuls, gnusort and so on). Each was run through the script on this page, so both sides used the plain command names.

CommandGNU 9.7uutils 0.10.0What breaks
timeout -s KILL 2 sleep 10; echo $?137124checks that tell a kill from a timeout
mkdir backup (exists)cannot create directory 'backup': File existsbackup: File existsgrep on stderr
ln -s a latest (exists)... File exists... Already existsgrep for File exists
chmod 999 finvalid mode: '999' + a Try lineinvalid digit found in stringgrep on stderr
wc -m on café with no locale set6 (bytes)5 (characters)byte counts in C-locale jobs
numfmt --from=iec 12Qerror, exit 2prints a 32-digit number, exit 0input validation
printf 'aGk=!!' | base64 -dprints hi, then error, exit 1prints nothing, error, exit 1partial-output recovery
printf '%q\n' 'a b''a b'a\ bstring compares on quoted output
pr -t -2 filecolumns down then across, tabscolumns across, padded to the pagereport formatting
tail -n1 f >> f on a file over 4 KBstops after one linenever stopsthe disk

What matched exactly, in the same container: sort with -t/-k/-n/-h/-V/-M/-g, uniq, cut (including multibyte), wc -l, head -n -1, tail -n +2, tr, seq, printf number and %b formats, stat -c, du -sh and --apparent-size, df -P, date with -d, +%s, -R and --iso-8601, touch -t, mktemp, install -m, split, join, comm, od, sha256sum -c, cp -a, mv and rm. Every other failure tested exited with the same code on both, even where the message differed.

Two problems reported against earlier uutils releases did not reproduce in 0.10.0: du -smc /var/log /var counting the subdirectory twice (reported on 25.10) and ls -v not sorting by version (uutils issue 14859, closed). A Unix & Linux question about sort -n -k1 -k2 -k3 -t. blamed uutils, but GNU 9.7 gave the same order: -k2 without an end field means "from field 2 to the end of the line", so -k1,1 -k2,2 -k3,3 or sort -V is the fix on both.

The tail Bug That Fills a Disk

Appending a file's last line to itself is an odd thing to do on purpose, and a common thing to do by accident in a log-rotation or "mark the end" script. The uutils tail in 26.04.1 reads the lines it is writing:

text
$ seq 2000 > big.log; wc -c big.log 8893 big.log $ timeout 5 tail -n1 big.log >> big.log; echo "uutils rc=$? lines=$(wc -l < big.log)" uutils rc=124 lines=2425542 $ seq 2000 > big.log; timeout 5 gnutail -n1 big.log >> big.log; echo "gnu rc=$? lines=$(wc -l < big.log)" gnu rc=0 lines=2001

Without the timeout 5, that loop runs until the filesystem is full. It is uutils issue 15057, open at the time of writing. Write to a temporary file and mv it into place instead; that pattern is correct on both implementations.

The Script

Save as coreutils-diff.sh on an Ubuntu 26.04 box or in an ubuntu:26.04 container. Give it one quoted command line. It runs that line twice, once with GNU coreutils first in PATH and once with uutils, each in a fresh copy of the current directory, and compares the exit status, stdout, stderr and the files the command left behind.

bash
#!/bin/bash # Script: coreutils-diff.sh # Purpose: A script that passed on GNU coreutils can change exit codes, stderr text or output under Ubuntu 26.04's Rust coreutils without failing loudly; this runs it under both and shows every difference. # Usage: ./coreutils-diff.sh 'COMMAND LINE' (runs it twice, each time in a fresh copy of the current directory) set -euo pipefail CHECK="✓" CROSS="✗" UU_DIR="${UU_DIR:-/usr/lib/cargo/bin/coreutils}" # where Ubuntu's rust-coreutils keeps one link per command GNU_PREFIX="${GNU_PREFIX:-/usr/bin/gnu}" # gnu-coreutils installs GNU as gnuls, gnusort, ... MAX_COPY_KB=51200 # copying a huge directory twice is a mistake, not a test [[ $# -eq 1 ]] || { echo "usage: $0 'COMMAND LINE'" >&2; exit 2; } CMDLINE="$1" [[ -d "$UU_DIR" ]] || { echo "$CROSS no uutils at $UU_DIR (set UU_DIR)" >&2; exit 2; } [[ -x "${GNU_PREFIX}ls" ]] || { echo "$CROSS no GNU coreutils at ${GNU_PREFIX}ls (apt install gnu-coreutils, or set GNU_PREFIX)" >&2; exit 2; } SIZE_KB=$(du -sk . | cut -f1) (( SIZE_KB <= MAX_COPY_KB )) || { echo "$CROSS current directory is ${SIZE_KB} KB; run this in a small scratch copy" >&2; exit 2; } WORK=$(mktemp -d) trap 'rm -rf "$WORK"' EXIT # Each implementation gets a bin dir of links named ls, sort, ... so the command line and every script it calls # resolve coreutils there first, and error messages carry the plain name (ls:, not gnuls:) on both sides. mkdir -p "$WORK/bin-gnu" "$WORK/bin-uutils" for uu in "$UU_DIR"/*; do name=${uu##*/} ln -s "$uu" "$WORK/bin-uutils/$name" [[ -x "${GNU_PREFIX}${name}" ]] && ln -s "${GNU_PREFIX}${name}" "$WORK/bin-gnu/$name" done run_as() { # impl local impl=$1 mkdir -p "$WORK/run-$impl" # Side effects (rm, mv, a log append) land in a private copy, so the second run starts from the same state. cp -a . "$WORK/run-$impl/dir" ( cd "$WORK/run-$impl/dir" && PATH="$WORK/bin-$impl:$PATH" \ bash -c "$CMDLINE" > "$WORK/run-$impl/out" 2> "$WORK/run-$impl/err" ) && rc=0 || rc=$? echo "$rc" > "$WORK/run-$impl/rc" } run_as gnu run_as uutils DIFFS=0 compare() { # label file if ! cmp -s "$WORK/run-gnu/$2" "$WORK/run-uutils/$2"; then echo "$CROSS $1 differs (< GNU, > uutils):" # diff exits 1 when files differ, which is the expected case here, not an error for set -e/pipefail. { diff "$WORK/run-gnu/$2" "$WORK/run-uutils/$2" || true; } | grep '^[<>]' | head -20 | sed 's/^/ /' DIFFS=$((DIFFS + 1)) fi } compare "exit status" rc compare "stdout" out compare "stderr" err # Files the command left behind are output too: a report it wrote, or a log it appended to. if ! diff -rq "$WORK/run-gnu/dir" "$WORK/run-uutils/dir" >/dev/null 2>&1; then echo "$CROSS files left behind differ:" { diff -rq "$WORK/run-gnu/dir" "$WORK/run-uutils/dir" || true; } | sed "s#$WORK/run-##g; s/^/ /" | head -20 DIFFS=$((DIFFS + 1)) fi if (( DIFFS == 0 )); then echo "$CHECK identical under GNU $("${GNU_PREFIX}ls" --version | head -1 | awk '{print $NF}') and uutils $("$UU_DIR/ls" --version | head -1 | awk '{print $NF}'): exit $(cat "$WORK/run-gnu/rc"), same stdout, stderr and files" exit 0 fi exit 1

Prerequisites

Ubuntu 26.04 (or any system with uutils in one directory and GNU coreutils available under a prefix; set UU_DIR and GNU_PREFIX to match), bash 4 or later, and diff. The current directory must be under 50 MB, because it is copied twice. No root.

How Does Each Check Work?

  • Two bin directories of symlinks. Each holds a link per command (ls, sort, ...) to one implementation. Prepending one to PATH makes the command line, and every script it calls by plain name, use that implementation. Because the links have the plain names, both sides print ls: in errors, so a difference in stderr is a real one, not a gnuls: prefix. Commands called by absolute path (/usr/bin/sort) bypass PATH and are not switched.
  • A fresh copy per run. cp -a . into a temp directory means rm, mv or a log append in the first run cannot change what the second run sees, and the copies can then be compared with diff -r.
  • Four comparisons. Exit status, stdout, stderr and the directory tree. The tree catches commands that write their result to a file instead of stdout.
  • diff ... || true. diff exits 1 when files differ, which is the case being reported, so it is kept from tripping set -e and pipefail.

What Does the Script Print?

Lines from real scripts, run in a scratch directory holding a small CSV and a file containing café:

text
$ coreutils-diff.sh 'sort -t, -k2,2n disk.csv | head -1' ✓ identical under GNU 9.7 and uutils 0.10.0: exit 0, same stdout, stderr and files exit=0 $ coreutils-diff.sh 'timeout -s KILL 2 sleep 10; echo "job exit: $?"' ✗ stdout differs (< GNU, > uutils): < job exit: 137 > job exit: 124 ✗ stderr differs (< GNU, > uutils): < bash: line 1: 504 Killed timeout -s KILL 2 sleep 10 exit=1 $ coreutils-diff.sh 'ln -s disk.csv latest; ln -s disk.csv latest 2>&1 | grep -c "File exists"' ✗ exit status differs (< GNU, > uutils): < 0 > 1 ✗ stdout differs (< GNU, > uutils): < 1 > 0 exit=1 $ coreutils-diff.sh 'wc -m < name.txt' ✗ stdout differs (< GNU, > uutils): < 6 > 5 exit=1 $ coreutils-diff.sh 'numfmt --from=iec 12Q; echo "numfmt exit: $?"' ✗ stdout differs (< GNU, > uutils): < numfmt exit: 2 > 15211807202738752817960438464512 > numfmt exit: 0 ✗ stderr differs (< GNU, > uutils): < numfmt: value too large to be printed: '1.52118e+31' (consider using --to) exit=1

The timeout case shows a second difference: with GNU, bash itself reports Killed. GNU timeout sends the signal to its whole process group, itself included, and its man page lists exit 137 for "COMMAND (or timeout itself) is sent the KILL (9) signal". The uutils timeout exits normally with 124, so bash has nothing to report.

The wc -m result depends on the locale. The container had none set (POSIX), where GNU counts bytes for -m; with LC_ALL=C.UTF-8 both printed 5. Set LC_ALL explicitly in any script that counts bytes, and use wc -c when bytes are what you mean.

How Do I Make a Script Safe on Both?

  • Exit codes: test for every value that can mean the same thing. For timeout, case $rc in 124|137) ... ;; esac. The Bash Exit Code Lookup has timeout's full table.
  • Error text: branch on exit codes, never on stderr wording. Where you must, match the stable part (exists, not File exists).
  • Locale: set LC_ALL at the top of any script that counts or sorts bytes.
  • Pin while you fix: gnutimeout, gnupr and the other gnu* names run GNU for one call, without switching the whole system. To switch the whole system, see Ubuntu Rust Coreutils: Check and Switch Back.

The rest of what changes in the upgrade, including sudo-rs, is in Ubuntu 26.04: What Breaks in Bash Scripts.

Frequently Asked Questions

Is Rust coreutils compatible with GNU coreutils?

Mostly. uutils aims to be a drop-in replacement and treats differences as bugs, and in tests for this page sort, cut, uniq, stat, date formats, seq, printf, readlink, split, mktemp and most others matched GNU 9.7 exactly. The differences are in edge cases: exit codes (timeout -s KILL), error message wording, locale handling in wc -m, invalid-input handling in numfmt and base64, and pr's column layout.

Why does timeout return 124 instead of 137 on Ubuntu 26.04?

With -s KILL, GNU timeout exits 137 (128 + 9, the signal it sent) and the uutils timeout in Ubuntu 26.04 exits 124, the code both use for an ordinary timeout. Scripts that check for 137 to detect a hard kill now miss it. Check for both values, or call gnutimeout explicitly while the scripts are updated.

How do I test my bash script against Rust coreutils?

Run it through coreutils-diff.sh on an Ubuntu 26.04 machine or container: ./coreutils-diff.sh './myscript.sh'. It builds two bin directories, one with GNU coreutils under their plain names and one with uutils, runs the command line once with each first in PATH, each time in a fresh copy of the current directory, and prints any difference in exit status, stdout, stderr or the files left behind.

Do error messages differ between uutils and GNU coreutils?

Some do. In uutils 0.10.0, mkdir prints mkdir: backup: File exists where GNU printed cannot create directory 'backup': File exists, ln -s says Already exists instead of File exists, chmod 999 says invalid digit found in string, and touch -d says Unable to parse date. Exit codes for those failures matched. Scripts that test exit codes are fine; scripts that grep stderr need updating.

Is there a bug where tail never stops on Ubuntu 26.04?

Yes, in uutils 0.10.0: tail -n1 big.log >> big.log keeps reading what it writes when the file is larger than 4096 bytes, and grew a 2,000-line file to over 2.4 million lines in five seconds in the test on this page. GNU tail stops after one line. It is reported upstream as uutils issue 15057. Write to a temporary file and move it into place instead.


Part of the bash snippets collection

Raw script, MIT licensed: scripts/uutils-vs-gnu-coreutils.sh on GitHub

PAID RESOURCE — $9

The Production Bash Toolkit

An operational script system + a 30-function shared library + a 52-page field guide. The production layer the free snippets don't cover.

Get the Toolkit →
curl -O bashlib-starter.sh

Get the bashlib starter

Ten functions I source into every script on my own boxes — strict-mode setup, an ERR trap that names the failing line, lock and timeout wrappers, and cleanup that runs on every exit path. One email, no sequence.

BashSnippets logo

Written by Travis

Creator of BashSnippets.xyz

bashsnippets.xyz/about

Related Snippets

Frequently Asked Questions

faq — snippet

Is Rust coreutils compatible with GNU coreutils?

Mostly. uutils aims to be a drop-in replacement and treats differences as bugs, and in tests for this page sort, cut, uniq, stat, date formats, seq, printf, readlink, split, mktemp and most others matched GNU 9.7 exactly. The differences are in edge cases: exit codes (timeout -s KILL), error message wording, locale handling in wc -m, invalid-input handling in numfmt and base64, and pr's column layout.

faq — snippet

Why does timeout return 124 instead of 137 on Ubuntu 26.04?

With -s KILL, GNU timeout exits 137 (128 + 9, the signal it sent) and the uutils timeout in Ubuntu 26.04 exits 124, the code both use for an ordinary timeout. Scripts that check for 137 to detect a hard kill now miss it. Check for both values, or call gnutimeout explicitly while the scripts are updated.

faq — snippet

How do I test my bash script against Rust coreutils?

Run it through coreutils-diff.sh on an Ubuntu 26.04 machine or container: ./coreutils-diff.sh './myscript.sh'. It builds two bin directories, one with GNU coreutils under their plain names and one with uutils, runs the command line once with each first in PATH, each time in a fresh copy of the current directory, and prints any difference in exit status, stdout, stderr or the files left behind.

faq — snippet

Do error messages differ between uutils and GNU coreutils?

Some do. In uutils 0.10.0, mkdir prints mkdir: backup: File exists where GNU printed cannot create directory 'backup': File exists, ln -s says Already exists instead of File exists, chmod 999 says invalid digit found in string, and touch -d says Unable to parse date. Exit codes for those failures matched. Scripts that test exit codes are fine; scripts that grep stderr need updating.

faq — snippet

Is there a bug where tail never stops on Ubuntu 26.04?

Yes, in uutils 0.10.0: tail -n1 big.log >> big.log keeps reading what it writes when the file is larger than 4096 bytes, and grew a 2,000-line file to over 2.4 million lines in five seconds in the test on this page. GNU tail stops after one line. It is reported upstream as uutils issue 15057. Write to a temporary file and move it into place instead.