diff --git a/docs/api/index.md b/docs/api/index.md
index e4f6803..f1b001f 100644
--- a/docs/api/index.md
+++ b/docs/api/index.md
@@ -132,7 +132,7 @@ This method performs parsing only. To both load and parse a CSV file, use [loadC
 * *options*: A CSV format options object:
   * *delimiter*: A single-character delimiter string between column values (default `','`).
   * *decimal*: A single-character numeric decimal separator (default `'.'`).
-  * *header*: Boolean flag (default `true`) to specify the presence of a  header row. If `true`, indicates the CSV contains a header row with column names. If `false`, indicates the CSV does not contain a header row and the columns are given the names `'col1'`, `'col2'`, etc unless the *names* option is specified.
+  * *header*: Boolean flag (default `true`) to specify the presence of a header row. If `true`, indicates the CSV contains a header row with column names. If `false`, indicates the CSV does not contain a header row and the columns are given the names `'col1'`, `'col2'`, etc unless the *names* option is specified.
   * *names*: An array of column names to use for header-less CSV files. This option is ignored if the *header* option is `true`.
   * *skip*: The number of lines to skip (default `0`) before reading data.
   * *comment*: A string used to identify comment lines. Any lines that start with the comment pattern are skipped.
diff --git a/docs/api/table.md b/docs/api/table.md
index 79187cd..a355559 100644
--- a/docs/api/table.md
+++ b/docs/api/table.md
@@ -647,6 +647,7 @@ Format this table as a comma-separated values (CSV) string. Other delimiters, su
 
 * *options*: A formatting options object:
   * *delimiter*: The delimiter between values (default `","`).
+  * *header*: Boolean flag (default `true`) to specify the presence of a header row. If `true`, includes a header row with column names. If `false`, the header is omitted.
   * *limit*: The maximum number of rows to print (default `Infinity`).
   * *offset*: The row offset indicating how many initial rows to skip (default `0`).
   * *columns*: Ordered list of column names to include. If function-valued, the function should accept a table as input and return an array of column name strings. Otherwise, should be an array of name strings.
diff --git a/src/format/to-csv.js b/src/format/to-csv.js
index db7ff67..1ae6ede 100644
--- a/src/format/to-csv.js
+++ b/src/format/to-csv.js
@@ -6,6 +6,9 @@ import isDate from '../util/is-date.js';
  * Options for CSV formatting.
  * @typedef {object} CSVFormatOptions
  * @property {string} [delimiter=','] The delimiter between values.
+ * @property {boolean} [header=true] Flag to specify presence of header row.
+ *  If true, includes a header row with column names.
+ *  If false, the header is omitted.
  * @property {number} [limit=Infinity] The maximum number of rows to print.
  * @property {number} [offset=0] The row offset indicating how many initial rows to skip.
  * @property {import('./util.js').ColumnSelectOptions} [columns] Ordered list
@@ -29,6 +32,7 @@ export default function(table, options = {}) {
   const names = columns(table, options.columns);
   const format = options.format || {};
   const delim = options.delimiter || ',';
+  const header = options.header ?? true;
   const reFormat = new RegExp(`["${delim}\n\r]`);
 
   const formatValue = value => value == null ? ''
@@ -37,16 +41,16 @@ export default function(table, options = {}) {
     : value;
 
   const vals = names.map(formatValue);
-  let text = '';
+  let text = header ? (vals.join(delim) + '\n') : '';
 
   scan(table, names, options.limit || Infinity, options.offset, {
-    row() {
-      text += vals.join(delim) + '\n';
-    },
     cell(value, name, index) {
       vals[index] = formatValue(format[name] ? format[name](value) : value);
+    },
+    end() {
+      text += vals.join(delim) + '\n';
     }
   });
 
-  return text + vals.join(delim);
+  return text;
 }
diff --git a/src/format/to-html.js b/src/format/to-html.js
index 54b7b26..1914d53 100644
--- a/src/format/to-html.js
+++ b/src/format/to-html.js
@@ -92,18 +92,22 @@ export default function(table, options = {}) {
     + tag('tbody');
 
   scan(table, names, options.limit, options.offset, {
-    row(row) {
+    start(row) {
       r = row;
-      text += (++idx ? '</tr>' : '') + tag('tr');
+      ++idx;
+      text += tag('tr');
     },
     cell(value, name) {
       text += tag('td', name, 1)
         + formatter(value, format[name])
         + '</td>';
+    },
+    end() {
+      text += '</tr>';
     }
   });
 
-  return text + '</tr></tbody></table>';
+  return text + '</tbody></table>';
 }
 
 function styles(options) {
diff --git a/src/format/to-markdown.js b/src/format/to-markdown.js
index 70d5c25..b579942 100644
--- a/src/format/to-markdown.js
+++ b/src/format/to-markdown.js
@@ -40,16 +40,19 @@ export default function(table, options = {}) {
     + names.map(escape).join('|')
     + '|\n|'
     + names.map(name => alignValue(align[name])).join('|')
-    + '|';
+    + '|\n';
 
   scan(table, names, options.limit, options.offset, {
-    row() {
-      text += '\n|';
+    start() {
+      text += '|';
     },
     cell(value, name) {
       text += escape(formatValue(value, format[name])) + '|';
+    },
+    end() {
+      text += '\n';
     }
   });
 
-  return text + '\n';
+  return text;
 }
diff --git a/src/format/util.js b/src/format/util.js
index c1105b4..9cfbc1b 100644
--- a/src/format/util.js
+++ b/src/format/util.js
@@ -1,5 +1,6 @@
 import inferFormat from './infer.js';
 import isFunction from '../util/is-function.js';
+import identity from '../util/identity.js';
 
 /**
  * Column selection function.
@@ -59,13 +60,15 @@ function values(table, columnName) {
 }
 
 export function scan(table, names, limit = 100, offset, ctx) {
+  const { start = identity, cell, end = identity } = ctx;
   const data = table.data();
   const n = names.length;
   table.scan(row => {
-    ctx.row(row);
+    start(row);
     for (let i = 0; i < n; ++i) {
       const name = names[i];
-      ctx.cell(data[names[i]].at(row), name, i);
+      cell(data[name].at(row), name, i);
     }
+    end(row);
   }, true, limit, offset);
 }
diff --git a/test/format/to-csv-test.js b/test/format/to-csv-test.js
index 661ca0a..6bb5542 100644
--- a/test/format/to-csv-test.js
+++ b/test/format/to-csv-test.js
@@ -23,12 +23,12 @@ const tabText = text.map(t => t.split(',').join('\t'));
 describe('toCSV', () => {
   it('formats delimited text', () => {
     const dt = new ColumnTable(data());
-    assert.equal(toCSV(dt), text.join('\n'), 'csv text');
+    assert.equal(toCSV(dt), text.join('\n') + '\n', 'csv text');
     assert.equal(
       toCSV(dt, { limit: 2, columns: ['str', 'int'] }),
       text.slice(0, 3)
         .map(s => s.split(',').slice(0, 2).join(','))
-        .join('\n'),
+        .join('\n') + '\n',
       'csv text with limit'
     );
   });
@@ -37,24 +37,40 @@ describe('toCSV', () => {
     const dt = new ColumnTable(data());
     assert.equal(
       toCSV(dt,  { delimiter: '\t' }),
-      tabText.join('\n'),
+      tabText.join('\n') + '\n',
       'csv text with delimiter'
     );
     assert.equal(
       toCSV(dt, { limit: 2, delimiter: '\t', columns: ['str', 'int'] }),
       text.slice(0, 3)
         .map(s => s.split(',').slice(0, 2).join('\t'))
-        .join('\n'),
+        .join('\n') + '\n',
       'csv text with delimiter and limit'
     );
   });
 
+  it('formats delimited text with header option', () => {
+    const dt = new ColumnTable(data());
+    assert.equal(
+      toCSV(dt, { header: false }),
+      text.slice(1).join('\n') + '\n',
+      'csv text without header'
+    );
+    assert.equal(
+      toCSV(dt, { header: false, limit: 2, columns: ['str', 'int'] }),
+      text.slice(1, 3)
+        .map(s => s.split(',').slice(0, 2).join(','))
+        .join('\n') + '\n',
+      'csv text without header and with limit'
+    );
+  });
+
   it('formats delimited text for filtered table', () => {
     const bs = new BitSet(3).not(); bs.clear(1);
     const dt = new ColumnTable(data(), null, bs);
     assert.equal(
       toCSV(dt),
-      [ ...text.slice(0, 2), ...text.slice(3) ].join('\n'),
+      [ ...text.slice(0, 2), ...text.slice(3) ].join('\n') + '\n',
       'csv text with limit'
     );
   });
@@ -63,7 +79,7 @@ describe('toCSV', () => {
     const dt = new ColumnTable(data());
     assert.equal(
       toCSV(dt, { limit: 2, columns: ['str'], format: { str: d => d + '!' } }),
-      ['str', 'a!', 'b!'].join('\n'),
+      ['str', 'a!', 'b!'].join('\n') + '\n',
       'csv text with custom format'
     );
   });