Bash

str_repeat() in Bash: Repeat a String N Times

By Nawawi JamiliPublished Updated 6 min read

Repeating a string is mostly a formatting job: a line of 40 dashes under a heading, an underline as wide as a title, a quick block of test data. PHP has str_repeat(). Bash has nothing built in with that name, and the obvious loop gets slow surprisingly quickly once the count grows.

This post rewrites my 2009 str_repeat(), fixes the two bugs it had, and compares three ways of doing it with timings measured on my own machine.

The function

bash
# str_repeat() -- print STRING repeated COUNT times
# Usage: str_repeat string count
str_repeat() {
    (( $# == 2 )) || return 2
    [[ $2 =~ ^[[:digit:]]+$ ]] || return 2
    local n=$((10#$2)) chunk=$1 out=""
    while (( n > 0 )); do
        if (( n & 1 )); then out+=$chunk; fi
        n=$(( n >> 1 ))
        if (( n > 0 )); then chunk+=$chunk; fi
    done
    printf '%s\n' "$out"
}

It prints the result followed by a newline and returns 0. A missing argument or a count that is not a non-negative whole number returns 2 and prints nothing.

How it works

The count is validated with a regular expression first. I use [[:digit:]] instead of [0-9] because, in some UTF-8 locales, the range also matches non-ASCII digits. The details are in is_num(). The check is inline now, so the function no longer depends on is_num being loaded.

$((10#$2)) forces base 10. Without it, Bash arithmetic reads a leading zero as octal: 010 would mean 8, and 08 would be an error.

The loop is "repeat by doubling". Instead of appending the string n times, it keeps a chunk that doubles on each pass (1, 2, 4, 8 copies...). It appends the chunk to the output whenever the matching bit of n is set. For a count of 10 (binary 1010) it appends the 2-copy chunk and the 8-copy chunk. That is a handful of concatenations instead of ten, and for a million copies it is about twenty instead of a million.

Every arithmetic step is an if (( )) or an assignment, never a bare (( )) that might evaluate to zero. That detail matters under set -e, as the original version showed when it failed (see below).

Usage

terminal
$ str_repeat "ab" 3
ababab
$ str_repeat "-" 40
----------------------------------------
$ str_repeat "=-" 10
=-=-=-=-=-=-=-=-=-=-
$ str_repeat "a b" 3
a ba ba b
$ str_repeat "é" 5
ééééé
$ printf '[%s]\n' "$(str_repeat "x" 0)"
[]
$ str_repeat "x" 010
xxxxxxxxxx
$ str_repeat "x" -1; echo $?
2
$ str_repeat "x" abc; echo $?
2
$ str_repeat "x"; echo $?
2

A count of 0 gives an empty string, as in PHP. Spaces, glob characters and multibyte text are copied as they are, because the string is only ever used inside quotes or on the right of an assignment.

The use I have most often is underlining a heading to match its length, combined with ${#var} from strlen():

terminal
$ title="Disk usage report"
$ str_repeat "=" "${#title}"; printf '%s\n' "$title"; str_repeat "=" "${#title}"
=================
Disk usage report
=================

What was wrong with the 2009 version

bash
str_repeat_2009() {
	[ $# -eq 2 ] || return 1;
	! is_num "$2" && return 1;
	local _cnt _ret="";
	while ((_cnt < $2 )); do
		_ret+="$1";
		((_cnt++));
        done;
	echo "$_ret";
	return 0;
}

With is_num loaded and no shell options set, this works. Take either away and it fails. First, without is_num defined:

output
./old.sh: line 3: is_num: command not found
status 1

The function returned 1, which looks exactly like "invalid count". Second, local _cnt declares the counter without a value. Arithmetic treats an unset variable as 0, so normally that is harmless, but under set -u it is fatal:

output
./old.sh: line 5: _cnt: unbound variable

Third, under set -e, the script died silently with exit status 1 and printed nothing at all. ((_cnt++)) is a post-increment: on the first pass it evaluates to the old value, 0, and an arithmetic command that evaluates to 0 returns status 1. set -e treats that as a failure and exits. It is one of the common set -e surprises covered in Bash strict mode.

Comparing the approaches

Besides the doubling loop, there are two common ways to repeat a string in Bash. The simple loop appends once per copy. The printf trick builds a string of n spaces with a field width and then replaces each space with the string:

bash
loop_repeat() {
    local i out=""
    for (( i = 0; i < $2; i++ )); do out+=$1; done
    printf '%s\n' "$out"
}
str_repeat_printf() {
    local pad
    printf -v pad '%*s' "$2" ''
    printf '%s\n' "${pad// /"$1"}"
}

I timed each one repeating "ab", output sent to /dev/null, with Bash 4.4.20 on a single machine (an Intel Core i7-8565U laptop). Treat the numbers as relative, not absolute:

CountSimple loopprintf + substitutionDoubling
1,0000.003 s0.001 s0.000 s
10,0000.032 s0.109 s0.001 s
100,0001.124 s9.734 s0.005 s
1,000,000not runnot run0.042 s
10,000,000not runnot run0.468 s

The surprise for me was the printf version. It is the one usually recommended, and it is fine for short padding. But in this Bash the global substitution got dramatically slower as the string grew. Ten times the count cost roughly a hundred times the time. An earlier run of the simple loop at one million copies took 1 minute 45 seconds, and I stopped the printf version at that size rather than wait for it. The doubling loop stayed well under a second even at ten million.

So for a separator line, any method will do. For generated test data or anything in the thousands, use the doubling version.

Pitfalls

Huge counts. Validation only checks that the count is digits. A count with 20 digits is larger than Bash's 64-bit integers and wraps around, and even a valid large count can ask for more memory than you have. I tried a 20-digit count to see what happens:

output
./lib.sh: xrealloc: cannot allocate 18446744071562068096 bytes

Bash aborts when it cannot allocate memory. It is not an error you can catch. If the count comes from user input, put a sensible upper limit on it before calling.

Trailing newlines. The function adds a newline after the result, and $(...) strips it again. If the string you are repeating itself ends in newlines, command substitution strips those from the end of the result too. Use printf '%s' in your own variant and write to a file, or build the value directly in a variable, if that matters.

Portability. [[ =~ ]], (( )), += and 10# all exist in Bash 3.2, so the function runs on the stock macOS /bin/bash. It is not POSIX sh.

The one-liner without a function

For a fixed-width rule, the printf padding trick is still the shortest thing to type and needs no function:

terminal
$ printf -v line '%*s' 30 ''
$ printf '%s\n' "${line// /-}"
------------------------------

History of this snippet

The April 2009 version depended on is_num(), left its counter uninitialised, used a post-increment that stops scripts running under set -e, and appended one copy per loop. The rewrite validates inline, forces base 10, uses doubling, and returns 2 on bad input. PHP's own behaviour is described on the str_repeat() manual page.