sheet CSV viewer
August 31, 2026 · View on GitHub
zsv's sheet command is a lightweight grid/spreadsheet-like terminal viewer
for tabular data, specifically intended to be useful in situations including:
- working within a terminal window, either on the local computer or a remote computer
- spreadsheet program such as Excel or OpenOffice is not installed
- you don't want to wait for a heavy program to load (esp with large files)
- working with large files
- working in a memory-constrained environment
Features
General
sheet is designed for:
- Minimal memory use (regardless of file size)
- Fast response time (regardless of file size)
- Use in any terminal environment on local or remote machine
Data input / parsing
sheet uses the zsv parser and supports its various options:
- Non-comma delimiter
- Multi-row headers
- UTF8 characters
- "Real-world" CSV including edge cases
- Operating system compatibility: Windows, MacOS, Linux, BSD
- Web assembly is not yet supported (requires better support for terminal emulation and threading)
For more about the parser in general, visit the README.md
Application
sheet features are in an early stage and still have significant room
for improvement (of existing features) and expansion (of new features).
Current features:
- View & navigate: view a tabular data file as a grid and navigate around
- vim-like key bindings
- emacs-like key bindings are still experimental
- both vim- and emacs- key bindings can be improved
- Search: find or filter, by literal text (ignoring case) or regex (PCRE2 syntax)
- SQL: filter by sql expression
- Sort: by any column (numeric-aware) or by a SQL expression
- Large files: quickly opens large files with background indexing after which full file can be navigated
- Pivot: generate pivot tables based on unique values or a user-provided SQL
expression. Current limitations:
- only generates a frequency count
- does not offer custom aggregation columns
- blocks the UI until the entire file has been processed
Other features under current consideration or plan:
- Command history and basic edit operations (Tab-completion of commands is already supported)
- Make status-bar messages persist until the next keypress. Today the main loop clears the status on every poll, so an error (a bad regex, an unknown command, "Not found") is shown for only about 0.2 seconds
- Accept non-ASCII input at prompts on Windows: PDCurses delivers a typed character as a UTF-16 code unit, which is not decoded yet, so such input is dropped there
- Reorder/remove column(s) or row(s)
- Adjustable column width
- Further performance optimizations
- parallelized find/filter
- parallelized indexing
- transformation internal API: return control after fixed # rows processed instead of # rows returned
- Tighter integration with overwrite capabilities
- Comparison
- Write-related features (edit, find/replace etc)
Comparison
Within sheet, side-by-side columns can be compared and color-coded by typing
:compare and then entering column ranges to compare e.g. 1 v 8 or 1-3 v 8-10 or City1 v City 2 or Name vs Name. Compared cells will be color coded
green if equal or red if different.
Can't find what you're looking for?
Feel free to suggest new features by creating a new issue.
Command list
Press ? to see a list of commands:
| Key(s) | Action | Description |
|---|---|---|
| :q | quit | Exit the application (also :quit) |
| escape | Leave the current view or cancel a… | |
| ^ | first | Jump to the first column |
| $ | last | Jump to the last column |
| first | Jump to the first column | |
| last | Jump to the last column | |
| k | up | Move up one row |
| j | down | Move down one row |
| h | left | Move left one column |
| l | right | Move right one column |
| up | Move up one row | |
| down | Move down one row | |
| left | Move left one column | |
| right | Move right one column | |
| pagedown | Move down one page | |
| pageup | Move up one page | |
| pagedown | Move down one page | |
| pageup | Move up one page | |
| g g | top | Jump to the first row |
| G | bottom | Jump to the last row (nG for specific row e.g. 10G) |
| / | find | Find a cell (any case); /re = regex; /re/i… |
| n | next | Find the next match |
| | | gotocolumn | Find a column by name and jump to the first match |
| \ | gotocolumnnext | Jump to the next column matching the find-column term |
| e | open | Open another CSV file |
| f | filter | Filter rows (any case); /re = regex; /re/i… |
| F | filtercol | Filter on this column |
| : | subcommand | Editor subcommand |
| ? | help | Display a list of actions and key-… |
| ^J | Follow hyperlink (if any) | |
| ^M | Follow hyperlink (if any) | |
| v | pivot | Group rows by the column under the… |
| V | pivotexpr | Group rows with group-by SQL expre… |
| where | Filter by sql expression | |
| o | sort | Sort rows by the column under the … |
| O | sortdesc | (alias: sort!) Sort rows… |
| sortexpr | Sort rows by SQL expression |
Quick usage guide
The below examples use the following files. Note that noaa.csv has a 2-row header:
curl -LO 'https://burntsushi.net/stuff/worldcitiespop_mil.csv'
curl -o noaa.csv 'https://data.pmel.noaa.gov/pmel/erddap/tabledap/pmel_co2_moorings_cb2d_135a_c444.csv?station_id,longitude,latitude,time,SST&time>=2024-01-01'
# some examples use tab-delimited data:
zsv 2tsv noaa.csv > noaa.tsv
The term "buffer" is used herein to describe data that is loaded into sheet
for viewing.
Loading a CSV file (into a buffer) for viewing
Run sheet and load a file:
zsv sheet worldcitiespop_mil.csv
To load another file for viewing, press e and enter the file name at the
prompt, then press Enter.
You can also use the global parser modifiers to modify how the file is parsed e.g.
$ head -5 noaa.tsv # view raw data
station_id longitude latitude time SST
degrees_east degrees_north UTC deg C
cce1 -122.51 33.48 2024-01-01T00:17:00Z 15.771
cce1 -122.51 33.48 2024-01-01T03:17:00Z 15.676
cce1 -122.51 33.48 2024-01-01T06:17:00Z 15.655
$ zsv sheet --header-row-span 2 -t noaa.tsv # open in sheet viewer; combine first 2 rows
Closing a buffer or the application
Press Esc to close the current buffer (a filter, sort or pivot result, the help
or errors view, ...) and return to the one beneath it. Esc never closes the
last file or data buffer you opened, so it leaves that view in place; type :q
(or :quit) followed by Enter to close the application. Quitting is
deliberately a command rather than a single keystroke, so it cannot happen by
accident.
Commands and Tab completion
Press : to enter a command. Pressing Tab cycles alphabetically through the
commands matching what you have typed so far, wrapping around at the end; with
nothing typed it cycles through every command. For example, : then p then
Tab yields pagedown, pageup, pivot, pivotexpr, and back to pagedown.
Editing the text restarts the cycle from the new prefix.
Navigation
Use arrow keys to move one row or column at a time, or Shift-right,
Shirt-left, G or g g to move to the last column, the first column, the
last row or the first row, respectively
Search syntax
find and filter take the same search syntax:
| You type | Meaning |
|---|---|
abc | literal, ignoring case: cells containing abc, ABC, Abc, ... |
/abc | regular expression abc (PCRE2 syntax), case-sensitive: how to match exact case |
/ab[Cc]/ | regular expression ab[Cc]; the closing slash is optional |
/abc/i | regular expression abc, ignoring case |
\/abc | literal, ignoring case: cells containing /abc |
A literal ignores case on both sides using Unicode simple case mapping, so
dömitz finds DÖMITZ and москва finds МОСКВА. This is per-code-point
mapping only: ss does not find ß, final sigma ς is distinct from σ, and a
decomposed o + combining diaeresis does not match the precomposed ö. I
folds to i (Unicode default, not the Turkish rule); İ (U+0130) and the Kelvin
sign K (U+212A) fold to i and k.
Regular expressions never use this rule: they are case-sensitive unless you add
/i. That also makes them the way to match a literal in exact case: /US finds
AUSTRIA but not Austria. Escape regex metacharacters when doing so (/a\.b, or
/\Qa.b\E). A case-sensitive regular expression that matches nothing says so in
the status bar, with a reminder that /i ignores case.
The find and filter prompts repeat this summary, e.g. Find (any case; /re = regex, exact case):.
A leading slash starts a regular expression. A trailing slash closes it only
when every character after that slash is a flag letter — so /http://x is the
one regular expression http://x, not http:/ with flags. i
(case-insensitive) is the only flag; an unrecognized letter, including an
uppercase I, means the slash was not a delimiter. To match a pattern that
really does end in a slash plus a flag letter, escape the slash: /a\/i is the
regular expression a\/i and matches a/i.
A \/ escape is recognized only at the very start; elsewhere a backslash is
passed through to the pattern (or matched literally). An empty regular
expression (/ or //) would match every cell, so it is rejected rather than
run.
Note that a regular expression is matched against each cell on its own, so ^
and $ anchor to the start and end of a cell.
When entering a search via a command rather than a prompt (:filter …), give it
unquoted and without spaces: the command lexer rejects backslash escapes inside
quotes, and splits on spaces. \/abc typed at the f prompt works; :filter "\/abc" does not.
Find
Press / and enter a search value to find the next matching cell. Press n to
find again. Matching ignores case unless you use a regular expression; see
Search syntax.
Filter
Press f or F to apply a global filter, or a filter on only the current
column, respectively. Matching ignores case unless you use a regular expression;
see Search syntax.
A filtered buffer's Row # column shows original row positions, including for
:where on files with a column named rowid (same rule as under Sort below).
For example, running a filter of /^Dö[nm] on worldcitiespop_mil.csv:
Pivot
Unique values of current column
Press v to generate a pivot table on the unique values of your current column.
For example, after navigating to the Country column in worldcitiespop_mil.csv
and pressing v:
In the above image, the cursor was moved to the value ir because that was the
value under the cursor when the pivot table was generated.
Within the pivot table buffer, you can "drill-down" on any row by navigating to
the row and pressing Enter. For example, from the above image, moving down three
rows to the je row with a Count of 18, then pressing Enter opens a new
buffer displaying the underlying 18 rows:
A drill-down buffer's Row # column shows original row positions, including
for files with a column named rowid (same rule as under Sort below).
Custom values / expression
Press V to generate a pivot table based on a custom SQL expression. For
example, after loading worldcitiespop_mil.csv and pressing V, then entering
case when latitude > 35 then '>35' else '<=35' end:
Sort
Press o to sort rows ascending by the column under the cursor, or O to sort
descending. Sorting opens the sorted data as a new buffer (press Esc to return
to the unsorted view); the Row # column keeps the original row numbers, so
with the cursor on Row # (the default position), o/O orders by original
row number. The same is available as a command:
:sort [<column>] [asc|desc] [n|f], where <column> is matched
case-insensitively (quote names containing spaces, e.g. :sort "Row #" desc)
and defaults to the column under the cursor. :sortdesc (alias :sort!, as in
vim) accepts the same arguments; it only differs in its default direction, so
an explicit asc overrides it.
Argument grammar: asc and desc are reserved words, never column names (a
column literally named asc is reachable only via the cursor keys), and the
last one given wins. A token made only of the letters n/f (any case) names
a column when the column slot is still empty and such a column exists —
leftmost wins — and otherwise sets the sort flags below. So with a column named
n, :sort Country n sorts Country with integer semantics while
:sort n Country is a usage error (n took the column slot), and :sort n n
is the explicit column-then-flag escape hatch. When flags combine, f beats
n.
Column sorts without a flag use the following policy:
- Cells that are empty, whitespace-only, or missing (short row) always sort last, regardless of direction (spreadsheet convention).
- Numeric-looking cells (after trimming, only chars
[0-9.eE+-]and at least one digit) sort numerically as a group before text (after text when descending, i.e. descending is the exact reverse of ascending among non-blanks). - Everything else sorts as text, leading/trailing whitespace ignored, with
ASCII-only case folding (
COLLATE NOCASEdoes not fold non-ASCII; documented limitation). - Locale formats are not parsed:
1,234and$50sort as text, not numbers.
Numeric sort flags: n and f
n— leading-integer sort, like vim's:sort nexcept the number must start the cell (embedded numbers are not extracted: vim readsitem12as 12, here it has no leading number). Cells with no leading number sort first when ascending — and last when descending, unlike the blank-cell rule above, which holds regardless of direction — in original order either way; the rest sort by the integer value of their leading digits (12abcreads 12,3.7reads 3,1e3reads 1,-5and+2read signed)..5has no leading digit, so it lands in the no-number group — first in original order, not "0" — while0x10has a leading digit and lands in the integer group with value 0. Integers at or beyond saturate and tie, deterministically in original order.f— float sort byCASTto REAL: token-for-token equal to vim 9.1's:sort fon ordinary decimal tokens (-5,.5,+2,3.7,10,1e3, non-numerics), where every cell without a leading decimal float counts as 0.0 and sorts inline at 0.0. Divergence from vim on strtod extensions: vim parses0x10as 16.0,infas infinity andnanspecially, while here all three cast to 0.0.1e400overflows to infinity and sorts largest.- Ties under both flags keep original row order in both directions —
deliberately unlike vim, whose
:sort! nreverses ties along with everything else. - The status bar distinguishes the flags:
(ascending, integer)forn,(ascending, float)forf. :sortexprtakes no flags; a trailing junk token simply fails to prepare and reports a SQL error in the status bar.
Stability and chained sorts
Sorts are guaranteed stable: equal-key rows keep their original relative order
(a rowid tiebreak, using whichever rowid spelling the file's columns do not
shadow — see the note below — SQLite's sorter happens to behave stably in
every configuration sheet can reach, but that behavior is undocumented, so
it is pinned explicitly). Because a sorted buffer's row identity is the previous
sort's row order, sorting a sorted buffer is a true secondary sort: sort by A,
then by B, and rows tied on B stay in A-order.
Notes and limitations:
- If the file has duplicate header names,
:sorttakes the deduplicated name as shown by SQL (e.g.a_2for the seconda), matching:wherebehavior. - Row numbers and the stability tiebreak are positional even when the file has
a column literally named
rowid(or_rowid_/oid, any case): the implicit SQLite rowid is referenced by whichever of its three spellings the file's columns do not shadow. Only a file declaring all three at once falls back to the shadowedrowidcolumn — deterministic, but no longer positional. - Each sort writes a full-size temporary copy of the data (deleted when its
buffer closes) and blocks the UI until complete, like
:whereand pivot.
Sort by SQL expression
:sortexpr <SQL expression> [asc|desc] sorts by an arbitrary SQL expression,
e.g. :sortexpr length(City) desc. Unlike column sorts, the direction applies
to the raw expression only — the blank-last/numeric-first policy above is not
applied, since the expression itself defines the ordering. When typed with
arguments after :sortexpr, the command lexer consumes " quoting and
collapses whitespace runs, so double-quoted identifiers and doubled spaces
inside '...' string literals do not survive; enter the expression at the bare
:sortexpr prompt for those (single quotes are not special to the lexer, so
'New York' survives either way).
Viewing / clearing errors
If any parsing errors occur, the status bar will indicate with a message ? for help, :errors for errors. Entering the command :errors will show the list of
errors, and entering the command :errors-clear will clear the errors and reset
the status bar.