Skip to content
Shell & TerminalDeep Dive Published Updated 7 min readViews unavailable

Zsh Array Indexing: One-Based Subscripts, Negative Positions, and Compatibility Options

Use Zsh array subscripts predictably, preserve element boundaries, and contain KSH_ARRAYS compatibility behavior to its intended scope.

Zsh indexed arrays are one-based by default. The first element is at subscript 1, the last element can be addressed with a negative subscript, and an index of zero does not silently mean the first item unless a compatibility option changes that behavior. Code copied from a zero-based language or a shell configured for KornShell compatibility can therefore select a different value than a reader expects.

The array’s index origin is part of the shell’s option state. A script that depends on defaults should make that contract visible; a function that enables compatibility behavior should keep it local. Arrays are lists of arguments, so selection and expansion must also preserve element boundaries when values contain whitespace, empty strings, or wildcard characters.

Index the intended element explicitly

Declare an indexed array and use a braced subscript when a value is selected for an argument. In the default Zsh mode, the first item has index 1. The special subscript -1 identifies the final element, -2 the previous one, and so on. Out-of-range and zero indices should be treated as cases to test, not as portable aliases for another valid element.

#!/usr/bin/env zsh
items=("first file" "" "third")

printf 'count=%s\n' $#items
printf 'first=<%s>\n' "${items[1]}"
printf 'last=<%s>\n' "${items[-1]}"
printf 'all elements:\n'
printf '<%s>\n' "${(@)items}"

The empty second element is intentional. The at-sign expansion flag causes each array member to remain a separate word inside double quotes, so the first item stays one argument and the empty item remains present. When debugging array selection, print a visible delimiter around every item and print the count separately. A plain display line can conceal whether an empty word disappeared.

Use negative indices when the intent is positional, such as selecting the latest element, but validate that the array is nonempty first. An empty array has no last element. A computed index can also become invalid after a filtering operation changes the array’s length, so derive the index from the current list rather than retaining a stale number.

Keep list expansion distinct from element selection

An array reference with one subscript selects an element; an expansion that selects all items produces a list. Those shapes matter when invoking a command. A command that expects one argument per file should receive a quoted array expansion, while a command that expects one serialized string needs an explicit join format. Do not rely on an implicit scalar conversion to preserve all list semantics.

files=("report 1.txt" "report 2.txt" "literal*.csv")

for file in "${(@)files}"; do
    printf 'candidate=<%s>\n' "$file"
done

command printf 'argument=<%s>\n' "${(@)files}"

The loop and the command receive individual elements. The wildcard remains data rather than expanding against the current directory. If a downstream program accepts options, use its documented end-of-options marker when appropriate and still validate the path policy. Quoting preserves word boundaries; it does not establish that every path is safe to process.

Avoid writing a value such as $items[1] and assuming the syntax has the same meaning in every Zsh option mode. In default mode it is a common short spelling, but the braced form is easier to audit and required for certain compatibility settings. For shared code, use a consistent braced style so a later option change cannot quietly alter parsing.

Understand KSH_ARRAYS before enabling it

The KSH_ARRAYS option changes more than the starting number. Under that mode, array elements are numbered from zero, a bare array parameter refers to its first element rather than the whole array, and braces are required to delimit a subscript. Code that was correct under default Zsh behavior can therefore change both its selected value and how many words an expansion creates.

Do not set KSH_ARRAYS casually in a user’s startup file to make one migrated script look familiar. The option changes the behavior of all code executed in that shell context, including functions and plugins that were written for native Zsh indexing. If compatibility is required, isolate it in a function or a small script whose complete behavior is tested under that mode.

function read_ksh_style_array {
    emulate -L zsh
    setopt KSH_ARRAYS

    local -a values
    values=("alpha" "beta")
    printf 'zero-based first=<%s>\n' "${values[0]}"
    printf 'one-based second=<%s>\n' "${values[1]}"
}

read_ksh_style_array

The function localizes option changes and intentionally demonstrates the alternate index origin. Keep the compatibility option inside the smallest possible boundary. Do not pass array expressions across that boundary without documenting the expected indexing mode, because the caller and callee may interpret subscripts differently.

The separate KSH_ZERO_SUBSCRIPT option treats index zero as an alias for the first element when KSH_ARRAYS is not enabled. It is a legacy compatibility behavior, not a way to convert an array to ordinary zero-based semantics: index 1 still selects the first element, so the two subscripts can refer to the same item. If a codebase has either compatibility option enabled, inspect it with the Zsh option-reporting builtins and test in the actual startup context.

Protect index calculations from option state

Subscripts are arithmetic expressions, so a computed index should be validated as a number and checked against the array’s current bounds. Do not interpolate untrusted strings into arithmetic syntax or use eval to manufacture an index. Shell arithmetic is a parser boundary; treat the input as data and reject unexpected forms before calculating positions.

function print_item_at {
    emulate -L zsh
    local requested_index=$1
    shift
    local -a values
    values=("$@")

    if [[ $requested_index != <-> ]]; then
        print -u2 -- 'index must be a non-negative decimal integer'
        return 64
    fi
    if (( requested_index < 1 || requested_index > $#values )); then
        print -u2 -- 'index is outside the array bounds'
        return 64
    fi

    print -r -- "${values[requested_index]}"
}

This helper deliberately uses the default one-based convention and rejects zero and negative inputs. The numeric validation uses Zsh’s documented pattern syntax; if the accepted number format needs leading signs, whitespace, or arbitrarily large values, define and test that policy rather than broadening it implicitly. The function uses its own local array so a caller’s options and values do not become hidden inputs.

For a list transformation, prefer array operations over manually shifting every element. When removing an item changes positions, recompute the target position afterward. When merging two arrays, choose whether duplicates are meaningful. When sorting, remember that the new order may invalidate an index captured before the sort.

Test option modes as part of the shell contract

Run a small test under a clean Zsh with user startup files disabled, then repeat under the real login or interactive startup mode. Print the state of KSH_ARRAYS and KSH_ZERO_SUBSCRIPT, count the array, and test the first, last, zero, and out-of-range subscript. Include a one-element array, an empty array, an empty string element, and an element containing whitespace.

If a plugin manager or framework changes options, record which file sets them and whether the change is localized. A correct isolated test can fail in a user’s session because an earlier startup file changed the parser mode. Conversely, a profile that accidentally relies on the user’s global KSH_ARRAYS setting may break in a clean automation shell. Test both contexts and pin the intended option contract in the function or script.

Avoid comparing array behavior by printing a joined list alone. Use a loop that marks each argument’s boundaries, and pass the result to a diagnostic helper that reports argument count and values. This tests the command interface rather than the terminal’s rendering. Do not log confidential filenames or tokens merely to prove that quoting works.

Operational checklist

Assume native Zsh arrays are one-based unless the script deliberately establishes another mode. Use explicit subscripts and quoted list expansions, validate an array before selecting its last element, and localize compatibility options such as KSH_ARRAYS. Inspect the actual option state when code behaves differently between a clean process and an interactive shell.

Zsh’s flexible option system makes array compatibility possible, but it also makes ambient parser state important. The safest code states its indexing assumptions near the function boundary, preserves every element as a distinct argument, and tests the option mode that production will actually use.

Related:

Sources:

Comments