← back to latest

Split a line into named variables without losing them to a subshell

IFS=, read -r date region product units revenue <<< "$(sed -n 2p sales.csv)"; echo "$region $units"

anatomy

IFS=,
Sets the field separator for this one command only. A variable assignment placed in front of a command lives in that command's environment, so your shell's IFS is untouched afterwards.
read
A shell built-in that reads one line from standard input and assigns each field to the next name in the list. It has to be a built-in: an external program could never set variables in your shell.
-r
Raw mode. Without it, a backslash in the input escapes the next character and silently disappears. You almost always want it.
date region product units revenue
One variable per field, in order. If the line has more fields than names, the last name gets everything that is left over, separators included.
<<<
A here-string. The shell feeds the word on its right to the command's standard input with a newline appended. No pipe is involved, so read runs in your current shell and its variables survive.
"$(sed -n 2p sales.csv)"
Command substitution that prints line 2 of the file, the first data row. The double quotes keep the line as a single word so nothing gets split or globbed before read sees it.
echo "$region $units"
Proof that the variables exist in the current shell after read returns.

Sample output

northeast 150

The line 2026-01-15,northeast,widget-a,150,4500.00 is now five shell variables. $region and $units are ready for a test, a URL, or a log message, and you never started awk.

When you would reach for it

You wrote echo "$line" | read -r a b c, and every variable came out empty. A config line, a key=value pair, or one row of a CSV needs to become named variables in a script, and you want the parsing to happen in the shell rather than in three calls to cut. It is also the fix for any while read loop that counts or collects something and finds the total back at zero once the loop ends.

Gotchas

  • In bash, every part of a pipeline runs in a subshell, so sed -n 2p sales.csv | IFS=, read -r date region ... sets the variables in a child process that exits immediately. zsh runs the last stage in the current shell, which is why the same line works for some people and not others. In bash scripts, shopt -s lastpipe changes the behavior, but only while job control is off, so it does nothing at an interactive prompt unless you also run set +m.
  • A here-string always appends a newline, so read returns 0 even when the substitution produced nothing. Test the variables, not the exit status: [ -n "$region" ] || echo "no row".
  • IFS=, is a plain split. A quoted CSV field like "Smith, J" gets cut at the comma inside the quotes. Use a real CSV parser (python3 -m csv, mlr) for anything with quoting. Empty fields are preserved, though: x,,z gives you an empty second variable, not a shifted third.
  • dash has no here-strings and stops with Syntax error: redirection unexpected. That bites when a script with a #!/bin/sh shebang lands on Debian or Ubuntu, where /bin/sh is dash. Use #!/usr/bin/env bash. On Alpine, BusyBox ash may or may not accept <<< depending on how it was built, so install bash for predictable behavior.

Variants

$ IFS=, read -r date region product units revenue < <(sed -n 2p sales.csv)

Process substitution instead of a here-string. Same result, and it streams rather than buffering the whole output first

$ while IFS=, read -r date region product units revenue; do echo "$region $revenue"; done < <(tail -n +2 sales.csv)

Every data row, skipping the header. Variables set inside the loop are still there after done

$ IFS=, read -r -a fields <<< "$(sed -n 2p sales.csv)"; echo "${fields[3]}"

Split into a bash array instead of named variables. zsh spells the option -A, not -a

lineage

Here-strings came from rc, the shell Tom Duff wrote for Plan 9, by way of Byron Rakitzis's Unix reimplementation of it. The commonly cited lineage is that zsh picked up the <<< syntax next, ksh93 followed, and bash added it in version 2.05b in 2002. That is why the feature works in every shell you are likely to type into on a laptop but fails in dash, which sticks closely to POSIX and has no here-strings.