The Kalem Book GitHub

This Book describes Kalem's main branch, which may be ahead of the latest release.

CSV

This chapter describes comma- and tab-separated files exactly as Kalem reads, shows, edits and writes them. It is a reference: every rule below is taken from the code (crates/kalem-core/src/csv.rs, the CSV commands in crates/kalem-core/src/builtin.rs and the file layer in crates/kalem-core/src/files.rs), and where Kalem departs from RFC 4180 or from what spreadsheets write, the departure is named. The manual's chapter CSV files in Part I describes the same features for a reader who only wants to use them.

The rule that governs everything here is the one that binds every standard mode: Kalem works on byte ranges in the file and never regenerates the file from a table. A CSV file that is opened, edited in one field and saved differs from the original in that field's bytes and nowhere else.

1. The oracle and what is tested

The standard is RFC 4180 together with the dialects spreadsheets write in practice. The oracle, as the design names it, is a round trip on a corpus of files written by Microsoft Excel, LibreOffice Calc and Google Sheets in several locales, plus the edge cases of RFC 4180: each file opens with its dialect found, takes an edit, and saves with every untouched byte as it was.

Part of the oracle State
RFC 4180 edge cases (quotes, doubled quotes, CR LF, line breaks in fields, an empty last field) Tested
LibreOffice Calc 24.2 with "Quote all text cells": LF, quoted text, unquoted numbers and dates, a line break in a field Tested, real export
LibreOffice Calc 24.2, cells formatted in Turkish: ;, grouped numbers with decimal commas, DD.MM.YYYY dates Tested, real export
Excel, Turkish locale: UTF-8 byte order mark, CR LF, ;, decimal commas, dates as text Tested, synthetic
Google Sheets Planned
Excel exports, and both in further locales (en-US, de-DE, fr-FR, …) Planned
Excel's UTF-16 "Unicode Text" export Planned
A test that every construct an editing command writes is valid RFC 4180 (TS.14) Planned

The tested files are in tests/csv/. The two LibreOffice files are real exports: tools/csv-oracle.sh regenerates them with LibreOffice Calc from the spreadsheets beside them (libreoffice.fods, libreoffice-tr.fods), and a regenerated file must equal the committed one byte for byte. The Excel file (excel-tr.csv) is written by hand in the style of Excel's "CSV UTF-8" in a Turkish locale, as Excel is not available to the tests: it follows Excel's documented behaviour (a field is quoted only when it holds the delimiter, a quote or a line break; numbers are never quoted) but is not an oracle. crates/kalem-core/tests/csv.rs opens each file as a document, sets one field, saves, and compares the bytes with the original. The unit tests of csv.rs cover dialect detection, RFC 4180 values, each editing operation's exact output, sorting, the conversions and a 100,000-row file; csv_commands in builtin.rs runs the commands on a document; both editors have a csv_grid test. The work items are group 2.7d of docs/todo.md; the corpus of the design (Google Sheets, Excel, more locales) is not there yet, so the design's table still lists CSV as planned.

2. Which files are CSV

A file opens in CSV mode when its extension is csv, tsv or tab (in any letter case), when an Emacs mode line or Local Variables: block names csv (-*- mode: csv -*- or -*- csv -*-), when a .txt file's first lines hold the same number of tabs (Excel's "Unicode Text" export, UTF-16 and tab-separated), or when the user chose CSV for it with Set Document Mode (view.setMode with mode: csv), which is remembered in the workspace settings under files.modes. The order of these checks is given in the chapter Plain text and code. The extension decides nothing about the delimiter: a .csv file with semicolons and a .tsv file with commas are both read by what their text holds.

3. Encoding and byte order mark

A CSV file is decoded like every other text file (files::decode_with):

  • a UTF-8 byte order mark is removed from the text and remembered;
  • a UTF-16 LE or BE byte order mark selects UTF-16, and is remembered;
  • without a byte order mark, valid UTF-8 is UTF-8;
  • anything else is decoded in the legacy encoding its bytes suggest (chardetng), such as Windows-1254 for a file Excel saved as "CSV (comma delimited)" on a Turkish system, and the status bar says which;
  • Reopen with Encoding and Save with Encoding override the guess.

On save the text is encoded back in the same encoding, with the byte order mark if the file had one. A character the encoding cannot hold stops the save with a message naming it. UTF-16 without a byte order mark contains NUL bytes and is therefore taken for a binary file, which Kalem does not open.

4. The dialect

A dialect has four parts (csv::Dialect):

Part Values How it is found
Delimiter , ; tab, vertical bar Detected from the text, or set
Quote " or another character " unless set
Header yes or no Detected from the first records, or set
CR LF yes or no Detected from the text

Detection (csv::detect) looks at a sample: the first 65,536 characters of the text (after the byte order mark is removed).

The dialect is detected once, when the document is first laid out, and kept for the document's life (DocumentState::csv_dialect): renaming a header cell to a number does not turn the header off, and editing the first line does not change the delimiter. Four commands change it by hand (see The commands): Set Delimiter, Set Quote Character, First Row Is a Header, and Detect Delimiter and Header Again, which forgets the kept dialect and detects it from the current text.

4.1. Excel's sep line

A file whose first line is exactly sep= followed by one ASCII character (Excel writes sep=;) takes that character as its delimiter without further detection; the line is not a record, so it is neither shown in the grid nor taken for a header, and it is kept in the file as it is. A sep= line with nothing after it names no delimiter. An Emacs mode line first (# -*- mode: csv -*-, which makes a file of any name CSV) is not a record either (csv::preamble): the commands, the filter, the sorts and the pinned header row start after either line. An Emacs Local Variables: block ending the file (its lines up to one with End:, in the last 3000 bytes) is not records (csv::body_end): the records end before it, and rows added go before it.

4.2. Line endings

The dialect's CR LF flag is set when the sample contains a carriage return followed by a line feed anywhere. It decides the line ending of rows Kalem adds and of Sort File. Independently, the file layer decides the document's line ending for saving: CR LF only when every line feed in the file follows a carriage return (as Emacs chooses between dos and unix), CR when the file has carriage returns and no line feed (Emacs's mac: classic Mac OS files), otherwise LF with every carriage return kept in the text as it is. A CR file is read with each carriage return turned into a line feed and saved with each line feed turned back, so its records are lines like any other file's.

4.3. The delimiter

Each candidate is tried in the order , ; tab and vertical bar. For a candidate, the sample is scanned with the full scanner (so delimiters inside quotes do not count) for at most 50 records; an empty record at the very end (the line after a final line feed) is not counted. Let m be the most common number of fields among those records, counting only numbers above one (the larger number on a tie), and s the number of records with m fields. A candidate for which no record has more than one field is out. The others are compared, in this order, by

  1. how many fields of those records read as malformed (below), fewer is better;
  2. s, more is better; the first record takes no part of its own, so a title or metadata first line of one field does not decide the delimiter;
  3. how many fields of the first twenty records read as numbers (with . or , as the decimal separator), more is better;
  4. how many of those fields are not numbers and hold another candidate delimiter, fewer is better;
  5. m, more is better;

and a tie on all five goes to the one tried first. When every candidate is out the delimiter is the comma.

So ürün;fiyat followed by elma;1,5 is read with ;: under , the fields ürün;fiyat and elma;1 hold a semicolon and 5 is the only number, while under ; the field 1,5 is a number. Likewise 1,5;2,5;3,5 splits at ; into three numbers.

4.4. The quote character

The quote is ". It is ' only when no field of the first 50 records, read with the delimiter found for ", is quoted with ", some field is quoted with ' when the file is read with ' and the delimiter found for it, and that reading splits at least as many records into the same number of fields. So 'name','note' followed by 'Ali','a, b' is read with ', and a file where both quotes occur, or neither, keeps ". Set Quote Character chooses another by hand.

4.5. The header row

The first record is taken for a header (looks_like_header) as follows:

  1. When the sample holds a single record, there is no header.
  2. A value looks like a number when it reads as one as the column statistics read numbers (csv::number, below): 1,234.5, 1.234,5 and 1 234,5 do; dates, version numbers, phone numbers and integers with leading zeros do not.
  3. When any value of the first record looks like a number, there is no header; but a year (1800 to 2199) beside a value that is text names a column, as in name,2024,2025.
  4. Otherwise, when any value of the next twenty records looks like a number, there is a header.
  5. Otherwise there is a header when every value of the first record is not blank and no two are equal.

The header matters to the grid (bold, pinned), to row moves and sorting (it stays first), to the statistics and the filter (it is not data), and to Convert to Org Table (a rule under it). First Row Is a Header sets it by hand.

5. The scanner

Kalem scans records itself rather than through the csv crate, because editing needs the byte range of every field, quotes included (csv::scan). Given a start offset and a dialect, it reads one record:

  1. A field starts. If its first byte is the quote, it is a quoted field: bytes are consumed up to a quote that is not followed by a second quote (a doubled quote is consumed as a pair); then every byte up to the next delimiter or line ending is also part of the field. If no closing quote comes, the field runs to the end of the text.
  2. Otherwise it is an unquoted field: bytes up to the next delimiter or line ending. A line ending is a line feed or a CR LF pair; a carriage return alone is part of the field, as the editor's lines end only at line feeds.
  3. A delimiter after the field starts the next field of the same record.
  4. Otherwise the record ends. The next record starts after the line ending, or at the end of the text.

Every field is kept as its byte range and a flag saying whether it was quoted; the record keeps its range without the line ending and the offset of the next record. A blank line is a record with one empty field. A final line feed does not start an empty record (Index). Blank lines are shown as empty rows but are not data: Sort File keeps them after the other rows, the filter neither counts nor matches them, and Insert Column leaves them blank.

The value of a field (csv::value) is its text as written when it is unquoted. When it is quoted: the opening quote is dropped, the closing one is the first quote not doubled (as the scanner finds it), doubled quotes before it become single quotes, and whatever follows the closing quote is appended as written. So "ab"c reads as abc, "a"b"c as ab"c (as Python's csv module reads it) and an unterminated "abc as abc.

5.1. Malformed fields

The lenient reading keeps every byte, but three things are reported (csv::problems), each as a warning with a code:

Code Where What
csv-unterminated-quote The opening quote A quoted field is never closed: the rest of the file is its value
csv-text-after-quote The text after the closing quote Text after the closing quote
csv-bare-quote The first quote A quote inside an unquoted value

In both editors the status bar shows the first problem of the record at the cursor in place of the column's numbers. kalem check reports them all for a CSV file (as FILE:LINE:COLUMN: warning[CODE]: MESSAGE, or in JSON), and --deny-warnings makes them fail; a CSV file is not checked as Org.

5.2. A lazy index

Records are found from the top only as far as a view asks (csv::Index): drawing the first screen of a 100,000-row file scans only its first records, and the whole file is scanned only by what needs all of it (the column operations, sorting, statistics, the filter and the conversions). The unit test large_files_open_lazily and the integration test large_files_lay_out_lazily check this.

With a filter or a sort on, the records the view shows, their order and what the filter keeps are worked out once for each version of the file, so moving the cursor reads nothing again; the status bar's numbers read the cursor's column of each record, not every field. The integration test a_large_file_at_each_keystroke_and_step times twenty steps of the cursor and a keystroke in a 100,000-row file with a filter and a sort on, against ceilings: the twenty steps take some 40 ms in a release build.

6. RFC 4180: conformance and deviations

RFC 4180 rule Kalem
Records end with CR LF CR LF and LF end a record, and what the file has is kept; a lone CR is part of the field (the scanner, above)
The last record may lack a line break Kept as it is
An optional header line Detected heuristically (above), or set by hand (First Row Is a Header)
Every record has the same number of fields Not required; short and long records are read and kept
Spaces are part of a field Yes; nothing is trimmed on reading
Fields with line breaks, quotes or commas are quoted Read; written quoted by encode (below)
A quote inside a quoted field is doubled Read and written
Only , separates fields Also ;, tab and vertical bar, detected
Text after a closing quote is invalid Accepted and kept as part of the value
An unterminated quoted field is invalid Accepted; it runs to the end of the file
A quote inside an unquoted field is invalid Accepted as a literal character
MIME parameters charset and header Not applicable: the encoding and header are detected

Constructs of CSV dialects outside the RFC that Kalem does not recognize, and therefore shows and keeps as ordinary data: comment lines, backslash escapes, a quote character other than " and ' unless set by hand, and blanks after a delimiter as in Python's skipinitialspace.

6.1. Quoting on write

Where Kalem writes a value it produced itself (a pasted spreadsheet row, Copy as Tab-Separated Values), it encodes it with csv::encode: the value is written in quotes, with every quote doubled, when it contains the delimiter, a quote, a carriage return or a line feed, or when it starts or ends with a space or a tab. Otherwise it is written as it is.

Value Written (comma dialect)
plain plain
a,b "a,b"
say "hi" "say ""hi"""
␣pad (a leading space) " pad"

Blanks at the ends are quoted because spreadsheets drop them otherwise; RFC 4180 permits quoting any field.

7. The grid

A CSV document is drawn as a grid over its text (csv::line_view). The layout (csv::Layout) is computed once per version of the text:

  • the dialect, as detected above;
  • a width for each column: the largest display width, in terminal columns, of the field's source text (quotes included) in the first 1,000 records, at most 40;
  • which columns hold numbers: those where at least four in five of the non-empty data values of the first 1,000 records are numbers (read as the column statistics read them) or dates (2026-09-29, 29.09.2026, 9/29/2026).

A line of the file that starts a record, and holds all of it, is drawn as a row: each field's source text as written, then padding to its column's width, then the delimiter drawn as a dimmed │ with a space on each side; in a column of numbers the padding comes first, so numbers and dates line up on the right as in a spreadsheet (the header row stays left). The last field is not padded unless it is a number. A field wider than 40 is not cut; the fields after it on that row shift right. The header row is bold. The fields are shown verbatim, so the cursor moves through their real characters and every keystroke edits the file at that place.

A record spanning several lines (a quoted line break) is a row of the grid as many lines high: each line shows the parts of the fields on it in their columns, the other cells blank and the bars where every row has them, and the row number on the first line, as a spreadsheet shows a cell of several lines. In the terminal editor a line longer than 16 KiB is shown as plain text too. Toggle Source View shows the whole file as text; Open as Plain Text switches the document to text mode.

When a header was detected, both editors keep the header row pinned at the top once it scrolls out of view.

The cell at the cursor (csv::cell_at) is the record holding the cursor and, in it, the first field whose end is at or after the cursor (the last field when the cursor is past them all).

8. Editing, and the exact edits

Every operation below is one transaction on the text: one undo step, shared with every other view of the document, and saved like any other change. Where an operation moves the cursor, it goes to the start of the target field, just inside the opening quote of a quoted one.

8.1. Typing

Typing in the grid inserts and deletes characters in the file at the cursor, as in plain text, with one exception: a delimiter, a quote or a line break typed inside a field belongs to the field's value (csv::typed, DocumentState::type_in_grid). In an unquoted field the field is rewritten quoted, the characters before the cursor and the typed ones inside the quotes, every quote doubled, and the cursor after what was typed; inside the quotes of a quoted field the characters are inserted with their quotes doubled. Other characters, blanks included, are inserted as they are, so a field is quoted only when it has to be. A line break goes into a value with Line Break in Cell (csv.lineBreak: Alt+Enter, as in Excel; Ctrl+J in a terminal, where Alt+Enter is Shift+Enter's), at the caret, or after the cell's text from Ready mode. With Vim's keys there are no Excel modes (below): typing in insert mode inserts at the cursor, a delimiter or a quote still going into the value. In the source view (Ctrl+/) typing is raw: a comma typed there is a delimiter. Enter inserts a line break, without copying the line's leading blanks or tabs as edit.newline does in other files (they are part of a value or empty fields), which starts a new record unless the cursor is inside quotes. The grid and the layout follow the new text at once.

The library also has a cell setter, csv::set_cell, which replaces only the field's bytes with the encoded value and pads a short record with empty fields; the round-trip tests use it to edit a cell.

8.2. The commands

The commands are in the category CSV and apply only in CSV documents (scope csv). Tab and Shift+Tab move between fields, and the keys of Org tables move, insert and delete rows and columns (Alt with the arrows, Alt+Shift with them); in the Emacs keymap, C-c ^ sorts by columns, C-c + sums the column and C-c ? names the cell, as in an Org table. The others are in the graphical editor's Table menu (rows, columns, filter, sorts and the spreadsheet habits below), on its toolbar (insert and delete row and column, Sort File, Filter Rows), in the File menu (Copy as Tab-Separated Values, Convert to Org Table, Open as Plain Text) and in the palette of both editors.

Command Title Key Changes the file
csv.nextField Next Field Tab Only past the last field
csv.previousField Previous Field Shift+Tab No
csv.cellBelow Cell Below Enter Only past the last row
csv.cellAbove Cell Above Shift+Enter No
csv.lineBreak Line Break in Cell Alt+Enter Yes, the cell
csv.insertRow Insert Row Alt+Shift+Down Yes
csv.deleteRow Delete Row Alt+Shift+Up Yes
csv.moveRowUp Move Row Up Alt+Up Yes
csv.moveRowDown Move Row Down Alt+Down Yes
csv.insertColumn Insert Column Alt+Shift+Right Yes
csv.deleteColumn Delete Column Alt+Shift+Left Yes
csv.moveColumnLeft Move Column Left Alt+Left Yes
csv.moveColumnRight Move Column Right Alt+Right Yes
csv.sortFile Sort File by Column   Yes
csv.sortFileBy Sort File by Columns   Yes
csv.fillDown Fill Down   Yes, the filled cells
csv.fillSeries Fill Series   Yes, the filled cells
csv.splitColumn Split Column   Yes
csv.joinColumns Join with Next Column   Yes
csv.removeDuplicates Remove Duplicate Rows   Yes
csv.transpose Transpose   Yes
csv.sumColumn Sum Column   With insert: the cell
csv.cellCoordinates Cell Coordinates   No
csv.goToCell Go to Cell   No
csv.filter Filter Rows   No
csv.clearFilter Show All Rows   No
csv.sortView Sort View by Column   No
csv.unsortView File Order   No
csv.copyAsTsv Copy as Tab-Separated Values   No
csv.openAsText Open as Plain Text   No
csv.convertToOrg Convert to Org Table   Writes a new file
csv.setDelimiter Set Delimiter   No
csv.setQuote Set Quote Character   No
csv.toggleHeader First Row Is a Header   No
csv.editField Edit Field SPC m b e Yes, the cell
csv.setField Set Field   Yes, the cell
csv.replaceInColumn Replace in Column   Yes, the changed cells
csv.killField Kill Field   Yes, the cell
csv.yankField Yank Field   Yes, the cell
csv.copyCells Copy Cells   No
csv.cutCells Cut Cells   Yes, the cells emptied
csv.pasteBlock Paste as Block   Yes, the covered cells
csv.recordView Record View   No
csv.frequencies Frequency Table   No
csv.histogram Histogram of Column   No
csv.hideColumn Hide Column   No
csv.showColumns Show All Columns   No
csv.autosizeColumn Autosize Column   No
csv.autosizeColumns Autosize All Columns   No
csv.widenColumn Widen Column   No
csv.narrowColumn Narrow Column   No
csv.setColumnWidth Column Width   No
csv.resetWidths Reset Column Widths   No
csv.toggleFrozen Freeze First Column   No
csv.detectDialect Detect Delimiter and Header Again   No

Arguments: csv.sortFile and csv.sortView take reverse (boolean); csv.filter takes text (string, required) and column (integer, from 0: the records whose value in that column is the text, an empty one too); csv.setDelimiter takes delimiter and csv.setQuote takes quote (strings, required: one ASCII character, and for the delimiter also tab, \t, space or a space; the delimiter and the quote must differ; anything else is refused with a message). The dialect set by hand lasts until the document is closed; it is not written to the file.

8.3. Moving between fields

  • Next Field goes to the next field of the record; from the last field, to the first field of the next record; from the last field of the last record, it inserts a new row (as Insert Row does) and goes to its first field, as Tab does in an Org table.
  • Previous Field goes to the previous field; from the first field, to the last field of the previous record; in the first field of the first record it does nothing.
  • The grid edits as Excel does, in three modes the status bar names. In Ready mode a cell is selected, framed and without a caret: typing replaces it (and enters Enter mode), Delete clears the selected cells, Backspace clears the cell and types into it, the arrows go from cell to cell (into a short record's missing cells and the empty columns too), Ctrl with them to the edge of the data, Shift with them extends the selection, Home goes to column A and Ctrl+Home and Ctrl+End to the first and the last cell. In Enter mode typing goes on after the text, and an arrow, Enter or Tab enters it and moves; Enter after a run of Tabs along a row goes to the row below in the column the Tabs started from. F2 or a double click is Edit mode: Left, Right, Home and End (and Ctrl+Home and Ctrl+End) move within the cell, Backspace and Delete delete within its value (never a delimiter or a quote), and Enter or Tab enters it. Escape, in Enter or Edit mode, puts the cell back as it was, undoing the entry's own steps, so that Undo afterwards does not bring it back. Quotes an entry put around a value (for a delimiter or a blank at an end, typed) go when a deletion leaves the value without need of them, unless the field had them in the file. Shift with Left and Right in Edit mode selects within the cell. A paste into a cell being edited goes in at the caret, its line breaks too; a paste in Ready mode leaves the cell selected, as in Excel. Each entry is an undo step of its own. The arrows and Tab skip hidden columns, and Right stops at the first empty column right of the data. The source view edits the text as text.
  • With Vim's keys (DocumentState::csv_vim) the grid has no Excel modes and the status bar names none: the cursor shows in the cell, Vim's motions and operators work on the text, insert mode inserts at the cursor, and j and k (and the arrows) go to the same column of the row shown below or above, a record of several lines being one row and a filter's or a sorted view's rows those shown (csv::view_vertical; with an operator they take the text's lines).
  • Cell Below (Enter) and Cell Above (Shift+Enter; Alt+Enter in a terminal) go to the same column of the next and previous record, as in a spreadsheet; Enter on the last record adds an empty one. In the source view Enter breaks the line as in any text. Up, Down and the page keys go by the rows shown too, in both editors, and stay on the first and the last row.
  • A selection from a cell in one row to a cell in another covers the rows the grid shows between them (csv::rows_between): with a filter on, the rows it hides between them are not selected, and with a sorted view on, rows shown elsewhere are not either; the commands on a selection (Delete Row, Fill, Delete, Copy, Cut, Paste) change those rows alone. With cells of several rows or columns selected, Delete Row and Delete Column delete every row or column the selection covers, and typing goes into the cell the selection started from, as a spreadsheet types into the active cell; Backspace clears that cell alone and types into it; typed over a selection within one cell, the text replaces it. Enter and Shift+Enter move the active cell down and up each column of the selection, Tab and Shift+Tab along each row, wrapping at the ends, as Excel moves in a selected range: the cells stay selected (DocumentState::csv_rect), typing goes into the active cell, and Delete clears them all, until an arrow, a click or a command ends them.
  • Backspace and Delete delete within a cell's value: at the start of a cell Backspace (at its end Delete) does nothing rather than delete the delimiter and merge two cells, the quotes of a quoted field stay, and a doubled quote goes as one character. A selection over more than one cell clears the cells, keeping the rows and the delimiters.
  • A short record's missing cells and the empty columns right of the data can be selected (by a click, Tab, Enter, Up and Down, Shift with the arrows, or Go to Cell) without a change to the file; typing there, in any mode, adds the empty fields up to the cell, then the value.

8.4. Rows

Insert Row (csv::insert_row) inserts, right after the cursor's record (before its line ending), a line ending followed by an empty record of n fields, that is n − 1 delimiters, where n is the larger of the number of columns in the layout and the number of fields of the cursor's record. The line ending inserted is CR LF when the text after the record starts with CR LF, or when the dialect is CR LF and the record ends the text; otherwise LF. A record of one empty field (a file of one column) at the end of the text gets a line ending after it too, as a final line feed alone starts no record. The cursor goes to the same column of the new row. With cells of several rows selected, as many empty records go after the last of them (csv::insert_rows), as Excel inserts.

id,name,note          id,name,note
1,"Ada",keep          1,"Ada",keep
2,Alan,x        →     2,Alan,x
                      ,,

Delete Row (csv::delete_row_list) removes the cursor's record, or the records a selection covers, each together with its line ending. When the record is the last one and has no line ending after it, the line ending before it goes instead. The cursor goes to the same column of the row shown after them, or of the one before when there is none.

Move Row Up and Move Row Down (csv::swap_rows) exchange the bytes of the cursor's record and the one shown above or below it (with a filter on, past the rows it hides); the line endings between them stay where they are, and so does every byte inside each record. When a header was detected, the header cannot move and no row can move above it. At the first or last record the command fails with "No row to move past"; in a sorted view, which has no order of the file's to move in, it is refused.

8.5. Columns

The column operations change every record of the file, each by the smallest edit, in one transaction.

Insert Column (csv::insert_column) inserts a delimiter at the start of field c (the cursor's column) in every record that has it, which makes an empty field there. A record with fewer than c + 1 fields gets delimiters at its end until it has c + 1 fields. With cells of several columns selected, as many columns go before the first of them (csv::insert_columns).

Delete Column (csv::delete_column) removes, in every record that has field c, that field and the delimiter after it; for the last field of a record, the delimiter before it; for a record with a single field, just the field's text. Records without field c are left alone. The cursor stays in column c, or moves one left when c was the last column.

Move Column Left and Right (csv::swap_columns) exchange the bytes of fields c and its neighbour in every record that has both; other records are left alone. They fail with "No column to move past" at the first column (left) or at the last field of the cursor's record (right).

id,name,note                 Insert Column at 1:  id,,name,note
1,"Ada",keep␣␣               1,,"Ada",keep␣␣
2,Alan,x                     2,,Alan,x

Delete Column 1:  id,note    Delete Column 2:     id,name
                  1,keep␣␣                        1,"Ada"
                  2,x                             2,Alan

Move Column Right at 0:  name,id,note
                         "Ada",1,keep␣␣
                         Alan,2,x

(␣ marks a space; the fields' quoting and trailing blanks stay as they were.)

8.6. Sort File by Column

Sort File by Column (csv::sort_file) reorders the records of the file by the cursor's column; with reverse, in descending order. When a header was detected it stays first. Two values compare (csv::compare), in a total order whatever the column mixes:

  1. numbers (read as the column statistics read them, below, and amounts with a percent sign after them, in hundredths, a currency symbol before or after them, or in parentheses, negative: 10%, $20, ₺5, (5)) first, two numbers by value;
  2. then dates (2026-09-29, 29.09.2026, 9/29/2026, or 29/9/2026 when the first part is past 12), by date;
  3. then text, letter by letter without case in the alphabet's order, Turkish's letters where the alphabet has them (ç after c, ğ after g, ı before i, ö after o, ş after s, ü after u); I lowercases to ı when the interface is in Turkish, to i otherwise. There is no other locale collation.

Each value's key is worked out once a sort. The sort is stable, a missing field compares as an empty value, and blank lines go after every other record, in their file order, in both directions (a blank line ending the file gets a line ending, so that it stays a record). The file is rewritten from its first record (a sep= line stays) to the end of its last record as the records' own bytes, unchanged, joined with the dialect's line ending; the line ending after the last record, if any, stays. So quoting inside records is kept, while line endings between records are made uniform. The cursor goes to the first data row, same column.

name,n            name,n
b,10       →      c,100       (column 0, reverse)
a,9               b,10
c,100             a,9

8.7. The spreadsheet habits

These commands borrow what people miss beside a spreadsheet; each is an ordinary edit of cell text (kalem_core::csv_tools), so the file keeps its dialect and every byte the command does not name.

  • Fill Down copies the first row of the selection into the same column of the other rows it covers (the rows shown, with a filter or a sorted view on); without a selection, the cell shown above into the cell at the cursor (a missing cell above empties it). Fill Series continues instead: numbers by the step of the two cells above (1 when there is one, or with a selection), with as many decimals as the values write and their decimal separator (1.50, 1.75; 0.125, 0.250, 0.375; 1,5 in a file ; delimits; integers past 2^53 exact); dates by days (2026-01-31, 2026-02-01; 28.02.2026, 01.03.2026; the step of two dates above in days); text ending in a number counts on, up or down (Item 9, Item 10; 007, 008; Item 2, Item 1, Item 0). A value that cannot continue is refused with a message, as is a fill with no cell above.
  • Remove Duplicate Rows deletes every data record equal, field by field after unquoting, to an earlier one, with its line ending; the header and blank lines stay, and the message counts the rows removed.
  • Transpose makes rows columns, padding short rows with empty fields, and writes the records with the dialect's delimiter and line ending.
  • Split Column (separator, a space when empty) splits the cursor's column into as many columns as the most parts a value has; values with fewer parts get empty fields. Join with Next Column (separator) writes the two values with the separator between them, or the one that is not empty. Both show a preview first: the first eight records as they would be, in the palette; choosing one makes the edit (the command again with confirmed), Escape leaves the file as it is.
  • Edit Field (SPC m b e in Vim; no key otherwise, so that Ctrl+C copies) edits the cursor's field in a one-line editor in the palette, its value unquoted; Set Field (value, and row and column counted from 0, the cursor's when left out) writes it quoted as the dialect needs.
  • Replace in Column (find, replace) replaces the text in every field of the cursor's column but the header, and says how many fields changed.
  • Kill Field empties the cursor's field and copies its value (a cell past the record's end stays as it is); Yank Field writes the last copied text into it, and with nothing copied is refused.
  • Paste as Block writes the clipboard over the cells from the cursor's down and to the right, as a spreadsheet pastes a range: lines of tab-separated values (what spreadsheets copy), or of the dialect's delimiter when no line has a tab; records past the last are added, short records get the fields they lack, and only the cells the block covers change (csv_tools::paste_block); with a filter or a sorted view on, the block goes down the rows shown. In the grid an ordinary paste does the same with text of several lines or with tabs, from the cursor's cell (a selection's top left one); a single value goes into the cell, quoted when it needs it (DocumentState::paste_in_grid). Paste as Plain Text inserts the text as it is; in the source view an ordinary paste goes in at the cursor, rows copied from a spreadsheet still converted to the file's delimiter (The clipboard, below).
  • A click on a column's letter selects the whole column (Select Column, csv.selectColumn), one on a row's number the row (Select Row, csv.selectRow), as in a spreadsheet. The edge between two letters still drags the column's width.
  • A selection from a cell in one row to a cell in another is a rectangle of cells, as in a spreadsheet: both editors paint the cells between the two corners, not the text between them. Copy (and Copy Cells) copies them as tab-separated values, a line a row, a value with a tab or a line break, or starting with a quote, in quotes, what a spreadsheet pastes as a range and what Paste as Block writes back (a single quoted cell pasted alone is unquoted); Cut (and Cut Cells) copies them and empties them, the rows and columns staying; so does a selection over cells of one row. Without a selection Copy and Cut take the cursor's cell, as in a spreadsheet. A selection within one cell stays text. In the source view a selection is always text.
  • Sort File by Columns (columns) sorts by several columns in order, named by the header's name (without case), by letter or by number, with - for descending (B, -A, 2 -1 or city -age); a name of no column is refused. Values compare as Sort File by Column compares them.
  • Sum Column shows the sum of the column's numbers (read as the statistics read them; the header left out; with a filter on, the rows shown) and copies it, as C-c + does in an Org table, with a decimal comma in a file ; delimits; with insert, it writes the sum of the other rows into the cell at the cursor (not on the header row).
  • Cell Coordinates names the cell as a spreadsheet and as an Org table do, B3 (@3$2), with the column's header; Go to Cell (cell) takes either form, or 3,2, and refuses a cell past the last row or column.

A command that leaves the text as it was (a sorted file sorted again, a field set to its value) leaves the document unmodified and makes no undo step.

9. Views that do not change the file

Filter Rows (csv.filter) shows only some records; the text is untouched and saving writes all of it. The text asked for is trimmed; an empty text shows all rows again, as Show All Rows does. A record is shown (csv::filter_rows) when it is the header, when it holds the cursor, or when any of its fields' values contains the text, compared case-insensitively (both lower-cased); a blank line never matches. With column, a record is shown when its value in that column is the text (an empty one too), as the Frequency Table filters. The status bar says "Filtered: m of n rows (“/text/”)" (“B = text/” for a column's value), where /n counts every record but the header and blank lines, and m those that match; the column's numbers after it count the rows shown. Setting the dialect by hand reads the filter again.

Sort View by Column (csv.sortView) shows the records in the order of the cursor's column, compared as Sort File compares, with the header first and blank lines last; the file keeps its order. Running it again on the same column turns the order to descending, and again back to ascending; reverse asks for descending directly. File Order ends it. With a filter on, the sorted view shows only the filtered records (csv::shown_lines). Filter and view sort are state of the open document, not of the file; they are not saved.

As in a spreadsheet, a sort and a filter stay as they were worked out until they are asked for again (csv::Frozen, each record moved through every edit as a mark is): a row whose sorted value is typed into stays where it shows, a row edited out of the filter stays shown, and a row inserted shows after the one it follows in the file. Filter Rows and Sort View work the view out again, the edits counted (as Excel's Reapply); a rewrite of most of the file (Sort File, Transpose) does too.

The grid looks like a spreadsheet (csv.sheet, on by default): the column letters in a shaded bar above the rows (A, B, … AA), lined up with the cells and scrolled with them, each row's number in a shaded gutter on its left, and the cell at the cursor marked in Excel's green, its row number and column letter too. The header row stays under the letters when the rows scroll. The editor's own line numbers are not drawn: the rows are numbered.

Three more toggles change how the grid draws the document, and are not saved either (csv::View); the settings csv.align_numbers, csv.rainbow and csv.coordinates say how new files start:

  • Align Numbers Right (on by default) turns the right alignment of columns of numbers off and on.
  • Rainbow Columns gives each column its color, six colors in turn, readable on light and dark themes (as Emacs's rainbow-csv).
  • Coordinate Grid (C-c } in the Emacs keymap, as in an Org table), without the spreadsheet look, shows each row's number in a dimmed gutter and each column's letters on the first row (A:name), with blanks of the same width below them so the columns stay aligned; Go to Cell and Cell Coordinates use the same names.

Two views list the data in the palette:

  • Record View shows the cursor's record as a form, one line a field (name: value, the header's names, or Column n; a line break shows as ⏎); choosing a line edits that field.
  • Frequency Table lists the values of the cursor's column with how many records hold each, the most frequent first, with a bar of their share; choosing one filters the rows to those with that value in the column (an empty one too), as many as the table counts.
  • Histogram of Column puts the numbers of the cursor's column in ranges of a round width (1, 2 or 5 times a power of ten, about as many ranges as Sturges' rule gives, at most twenty), each with how many records fall in it and a bar; fields that are not numbers are left out. Choosing a range goes to its first record.

9.1. Columns

The columns the grid shows, and their widths, are view state too (csv::Columns): never written to the file, kept while the document is open. Insert, Delete and Move Column carry them, and a sorted view's column, along with their columns (csv::remap_columns); Transpose starts them again.

  • Hide Column takes the cursor's column out of sight, with its delimiters; the cursor goes to the next column that shows. The last column that shows cannot be hidden. Show All Columns shows them again.
  • Autosize Column makes the cursor's column as wide as its widest value (of the first ten thousand records), however long; Autosize All Columns does it for every column. Widen Column and Narrow Column change the width drawn by two characters, and Column Width (width, 2 to 500) sets it. A value longer than a width set so shows cut, its last character …, but whole in the cell at the cursor. Reset Column Widths goes back to the widths the grid chooses (the widest value of the first thousand records, at most forty characters). In the graphical editor a column's right edge in the letters bar sets its width by dragging, and fits it to its values by a double click.
  • Freeze First Column keeps the first column that shows, with the row numbers, at the left edge when the rows scroll sideways, as a frozen pane in a spreadsheet; its letter stays over it in the letters bar. With it on, rows do not wrap. In the graphical editor a column is not yet widened by dragging its edge.

With Vim keys, Doom's local leader has the keys of its csv-mode (:lang data) and of Org tables: SPC m a autosizes the columns, SPC m u resets their widths, SPC m s and SPC m S sort by columns and by the cursor's column, SPC m k deletes the column, SPC m t transposes; SPC m b a, SPC m b e, SPC m b s, SPC m b i c, SPC m b i r, SPC m b d c and SPC m b d r align, edit the field, sort, and insert and delete columns and rows.

10. The clipboard

Copy as Tab-Separated Values (csv.copyAsTsv) copies the records the selection touches, from the record holding its start to the record holding its end (the whole file without a selection). Each field's value is decoded as above and encoded again with a tab as the delimiter (csv::to_tsv), so a value with a tab, a quote, a line break or blanks at its ends is quoted; records are joined with LF and no line ending follows the last. This is the form spreadsheets paste.

Pasting into a CSV document (csv::pasted, called by DocumentState::paste) converts rows copied from a spreadsheet: when the document's delimiter is not a tab and the pasted text contains a tab, the text is read as tab-separated records (quote "), each value is encoded with the document's dialect, fields are joined with the document's delimiter and records with LF, and a final line feed is kept if the pasted text had one. The result is inserted at the cursor as text, replacing the selection. In a CR LF document every inserted line feed becomes CR LF. Paste as Plain Text skips the conversion; pasting into a tab-separated file inserts the text as it is.

11. Convert to Org Table

Convert to Org Table (csv.convertToOrg) writes a new file, NAME.org beside NAME.csv, and opens it; the CSV file does not change. It needs the document to have a file (otherwise "Save the file first") and refuses to overwrite an existing NAME.org. The table (csv::to_org_table) has one row per record; each value has its runs of whitespace, line breaks included, collapsed to one space, and every vertical bar written as \vert{}; when a header was detected and there is more than one record, a rule follows the first row. The table is then aligned as org-table-align aligns it (numbers to the right).

name,n          | name |   n |
b,10      →     |------+-----|
a,9             | b    |  10 |
c,100           | a    |   9 |
                | c    | 100 |

The other direction, and CSV import into an existing Org document, are Org's table commands (Import Table, Export Table), described with Org tables in the Org chapter. Import Table reads a CSV file as this chapter does when Emacs's reader would misread it (a delimiter other than comma or tab, a line break inside quotes).

12. Column statistics

The status bar shows, for the cursor's column, statistics over the data records (the header skipped) (csv::column_stats). A value counts as a number when, after trimming and removing spaces, no-break spaces and apostrophes, it reads as a number as spreadsheets write it (csv::number): with both a comma and a full stop, the last of them is the decimal point (1,234.5 and 1.234,5 are 1234.5); with commas only, they group thousands when every group after the first has three digits and either there are several (1,234,567) or the file is not delimited by ; (where European spreadsheets write decimal commas: 1,5, and 1,234 reads as 1.234); with several full stops in groups of three, they group thousands (1.234.567), and in a file delimited by ; a single full stop before three digits does too (1.234 is one thousand two hundred thirty-four there, as Turkish spreadsheets write it, and 1.234 elsewhere). Spaces count only as they group thousands (1 234,5). Not numbers: infinities and NaN, dates (29.09.2026, 2026-09-29), version numbers (1.2.3), phone numbers (+90 532 123 45 67, 0532 123 45 67) and integers with leading zeros, which are identifiers (05321234567, 007). Values that are not numbers are left out. The same reader decides the header row (a first record with a number in it is data) and how Sort File compares, so the three always agree. The status bar shows "Count: n Sum: … Average: … Min: … Max: …", each number written as an integer when it is whole and otherwise with up to six decimals (from 10^15 on, in powers of ten); nothing is shown when the column has no numbers. With a filter on, the filter's count comes first, and the numbers are those of the rows shown. Amounts with a percent sign (in hundredths), a currency symbol or in parentheses (negative) count as numbers too (csv::quantity), in the statistics, Sum Column, the histogram, the right alignment, the header's detection and the sorts; sorting also reads dates (Sort File by Column).

13. Saving

Saving writes the text in the file's encoding, with its byte order mark, and, in a CR LF document, a carriage return before every line feed that lacks one (commands and typing insert bare line feeds). This includes a line feed inside a quoted field: in a CR LF file, a line break typed in a field is saved as CR LF, as Excel writes it. The setting editor.trim_trailing_whitespace does not apply to CSV files: trailing tabs are empty fields and blanks at the end of a last field are part of its value. Saving is atomic, as for every file.

14. Known differences and limits

  • Of quote characters only " and ' are detected; another is set by hand.
  • Only four delimiters are detected: , ; tab and vertical bar; others can be set by hand or with a sep= line.
  • Column widths are measured on the source text, quotes included, from the first 1,000 records only; a value of several lines by its widest line.
  • The dialect's CR LF flag (any CR LF in the sample) and the document's line ending (every LF after a CR) can disagree on a file with mixed line endings: rows added and Sort File use CR LF while saving adds no carriage returns.
  • The column operations scan the whole file; a filtered or sorted view maps its records through each change instead of reading them again.
  • Lenient reading: text after a closing quote, unterminated quotes and bare quotes are accepted, and reported (see Malformed fields) rather than refused.