# How to Export Data from Retool to CSV

- Tool: Retool
- Difficulty: Beginner
- Time required: 15-20 min
- Compatibility: Retool Cloud and Self-hosted
- Last updated: March 2026

## TL;DR

Enable the built-in Export button in the Table Inspector to let users download visible table data as CSV. For custom exports with specific columns, formatting, or filtered data, use utils.downloadFile(csvContent, 'export.csv', 'text/csv') in a JS Query to programmatically generate and trigger a CSV download.

## Two Ways to Export CSV from Retool

Retool provides two approaches for CSV export. The built-in table export button is the simplest — toggle it on in the Table Inspector and users get a download button that exports the currently visible table data (respecting filters and current page). For more control over what gets exported, how it's formatted, and which columns are included, use utils.downloadFile() in a JS Query to generate a custom CSV string and trigger a browser download.

This tutorial covers both approaches, plus techniques for exporting all pages (not just the current page), formatting dates and currencies for spreadsheet compatibility, and adding a custom filename with today's date.

## Before you start

- A Retool app with a Table component connected to a query
- Basic familiarity with JavaScript string operations
- Understanding of Retool JS Queries

## Step-by-step guide

### 1. Enable the Built-in Table Export Button

Select your Table component. In the Inspector, scroll to the Interaction section and find 'Allow downloading as CSV' (or 'Download CSV' toggle depending on your Retool version). Toggle it on.

A download icon appears in the table header. Clicking it downloads the currently visible table data as a CSV file. The export includes all visible columns with their current display values, respects any active filters, and uses the column display names as headers.

**Expected result:** A download CSV icon appears in the table header, and clicking it triggers a browser file download.

### 2. Create a Custom CSV Export with utils.downloadFile()

For full control over your CSV output, create a JS Query that builds a CSV string and uses utils.downloadFile() to trigger a browser download. This approach lets you: include all rows (not just the current page), choose specific columns, format values, and set a custom filename.

uts.downloadFile() takes three arguments: the file content (string), the filename, and the MIME type ('text/csv').

```
// JS Query: exportToCSV
// Triggered by a Button component

// Get the data to export
const rows = query1.data || [];

// Define custom columns for export
const columns = [
  { key: 'id', header: 'ID' },
  { key: 'first_name', header: 'First Name' },
  { key: 'last_name', header: 'Last Name' },
  { key: 'email', header: 'Email' },
  { key: 'status', header: 'Status' },
  { key: 'created_at', header: 'Created Date' },
  { key: 'revenue', header: 'Revenue (USD)' }
];

// Build CSV header row
const headerRow = columns.map(col => col.header).join(',');

// Build CSV data rows
const dataRows = rows.map(row =>
  columns.map(col => {
    let value = row[col.key];

    // Format dates
    if (col.key === 'created_at' && value) {
      value = moment(value).format('YYYY-MM-DD');
    }
    // Format revenue
    if (col.key === 'revenue') {
      value = Number(value || 0).toFixed(2);
    }

    // Escape CSV: wrap in quotes if contains comma, quote, or newline
    const str = String(value ?? '');
    return str.includes(',') || str.includes('"') || str.includes('\n')
      ? '"' + str.replace(/"/g, '""') + '"'
      : str;
  }).join(',')
);

// Combine header and data rows
const csvContent = [headerRow, ...dataRows].join('\n');

// Generate filename with today's date
const today = moment().format('YYYY-MM-DD');
const filename = `users-export-${today}.csv`;

// Trigger browser download
utils.downloadFile(csvContent, filename, 'text/csv');

return { exported: rows.length };
```

**Expected result:** Clicking the export button downloads a well-formatted CSV file with custom headers and formatted values.

### 3. Export All Pages (Not Just the Current Page)

With server-side pagination, query1.data only contains the current page. To export all matching rows, create a separate SQL query named 'getAllForExport' that uses the same WHERE filters but without LIMIT/OFFSET. Trigger this query in your export JS Query before building the CSV.

Add a loading notification so users know the export is fetching all data.

```
// SQL Query: getAllForExport
// Same WHERE as main paginated query but no LIMIT/OFFSET
SELECT id, first_name, last_name, email, status, created_at, revenue
FROM users
WHERE
  ({{ searchInput.value || '' }} = ''
   OR first_name ILIKE {{ '%' + (searchInput.value || '') + '%' }}
   OR email      ILIKE {{ '%' + (searchInput.value || '') + '%' }})
  AND ({{ statusFilter.value || '' }} = '' OR status = {{ statusFilter.value }})
ORDER BY created_at DESC;

// JS Query: exportAllToCSV
utils.showNotification({
  title: 'Preparing export...',
  description: 'Fetching all matching rows',
  notificationType: 'info',
  duration: 2
});

// Fetch all rows
const result = await getAllForExport.trigger();
const rows = result.data;

// Build CSV (same logic as previous step)
// ... (insert CSV building code here)

utils.showNotification({
  title: 'Export ready',
  description: rows.length + ' rows exported',
  notificationType: 'success',
  duration: 3
});
```

**Expected result:** The export fetches all matching rows (not just the current page) and downloads them as a complete CSV.

### 4. Export Only Selected Rows

For targeted exports, let users select specific rows in the table and export only those. Use table1.selectedRows (the multi-select array, available when row selection is enabled in the Table Inspector) as the data source for your CSV.

Enable multi-row selection in the Table Inspector: Interaction → Selection mode → 'Multiple rows'.

```
// JS Query: exportSelectedRows
// Uses table1.selectedRows (multi-select array)

const rows = table1.selectedRows;

if (!rows || rows.length === 0) {
  utils.showNotification({
    title: 'No rows selected',
    description: 'Please select at least one row to export.',
    notificationType: 'warning',
    duration: 3
  });
  return { exported: 0 };
}

// Build CSV from selected rows only
const headers = Object.keys(rows[0]).join(',');
const dataRows = rows.map(row =>
  Object.values(row).map(v => {
    const str = String(v ?? '');
    return str.includes(',') ? `"${str}"` : str;
  }).join(',')
);

const csv = [headers, ...dataRows].join('\n');
utils.downloadFile(csv, 'selected-rows.csv', 'text/csv');

return { exported: rows.length };
```

**Expected result:** Only the user-selected rows are included in the CSV download.

### 5. Add a UTF-8 BOM for Excel Compatibility

CSV files opened in Microsoft Excel may show garbled characters (especially for international characters like accents, Chinese, etc.) without a UTF-8 BOM (Byte Order Mark). Add the BOM character as the first character of your CSV string to ensure Excel reads it correctly.

```
// Add UTF-8 BOM for Excel compatibility:
const BOM = '\uFEFF';
const csvWithBOM = BOM + csvContent;

utils.downloadFile(csvWithBOM, 'export.csv', 'text/csv;charset=utf-8;');
```

**Expected result:** The CSV file opens correctly in Microsoft Excel with all characters displayed accurately.

### 6. Add an Export Button with Loading State

Create a Button component for the export action. Set its label to 'Export CSV'. In the Inspector's Interaction section, add an event handler: on Click → Run JS Query → exportToCSV. Set the button's 'Disabled' property to {{ exportToCSV.isFetching }} so it disables during the export to prevent double-clicks.

Change the button label dynamically: {{ exportToCSV.isFetching ? 'Exporting...' : 'Export CSV' }}.

```
// Button label expression:
{{ exportToCSV.isFetching ? 'Exporting...' : 'Export CSV (' + (query1.data?.length || 0) + ' rows)' }}

// Button disabled expression:
{{ exportToCSV.isFetching }}

// Button tooltip:
'Download current view as CSV'
```

**Expected result:** The export button shows a loading state while the export runs and displays the row count for user clarity.

## Complete code example

File: `JS Query: exportToCSV`

```javascript
// JS Query: exportToCSV
// Exports current query data with custom formatting
// Triggered by 'Export CSV' button

const rows = query1.data || [];

if (rows.length === 0) {
  utils.showNotification({
    title: 'Nothing to export',
    description: 'The table has no data.',
    notificationType: 'warning',
    duration: 3
  });
  return { exported: 0 };
}

// Column definitions: key = data field, header = CSV column name
const columns = [
  { key: 'id',          header: 'ID' },
  { key: 'first_name',  header: 'First Name' },
  { key: 'last_name',   header: 'Last Name' },
  { key: 'email',       header: 'Email Address' },
  { key: 'department',  header: 'Department' },
  { key: 'status',      header: 'Status' },
  { key: 'revenue',     header: 'Revenue (USD)' },
  { key: 'created_at',  header: 'Created Date' }
];

// Helper: escape a value for CSV
function escapeCSV(value) {
  const str = String(value ?? '');
  if (str.includes(',') || str.includes('"') || str.includes('\n')) {
    return '"' + str.replace(/"/g, '""') + '"';
  }
  return str;
}

// Build header row
const headerRow = columns.map(c => c.header).join(',');

// Build data rows with formatting
const dataRows = rows.map(row =>
  columns.map(col => {
    let val = row[col.key];
    // Format dates as YYYY-MM-DD for spreadsheet compatibility
    if (col.key === 'created_at' && val) {
      val = moment(val).format('YYYY-MM-DD');
    }
    // Format numbers to 2 decimal places
    if (col.key === 'revenue') {
      val = Number(val || 0).toFixed(2);
    }
    // Uppercase status
    if (col.key === 'status' && val) {
      val = String(val).toUpperCase();
    }
    return escapeCSV(val);
  }).join(',')
);

// UTF-8 BOM + CSV content
const BOM = '\uFEFF';
const csv = BOM + [headerRow, ...dataRows].join('\n');

// Filename with timestamp
const ts = moment().format('YYYY-MM-DD');
const filename = `users-export-${ts}.csv`;

// Trigger download
utils.downloadFile(csv, filename, 'text/csv;charset=utf-8;');

return { exported: rows.length, filename };
```

## Common mistakes

- **Built-in export only downloads the current page, missing paginated rows** — undefined Fix: For complete exports with server-side pagination, create a separate SQL query without LIMIT/OFFSET and use it as the source for a programmatic JS Query CSV export.
- **CSV values with commas not being quoted, causing extra columns in the spreadsheet** — undefined Fix: Always escape CSV values: wrap strings containing commas, quotes, or newlines in double quotes and replace internal double quotes with two double quotes.
- **International characters showing as garbled text when opened in Excel** — undefined Fix: Add a UTF-8 BOM (\uFEFF) as the first character of the CSV string. Use 'text/csv;charset=utf-8;' as the MIME type in utils.downloadFile().
- **Dates exporting as ISO timestamps that Excel doesn't recognize as dates** — undefined Fix: Format dates as 'YYYY-MM-DD' using moment(value).format('YYYY-MM-DD') before including them in the CSV. This format is universally recognized by spreadsheet applications.

## Best practices

- Always escape CSV values that contain commas, double quotes, or newlines — wrap them in double quotes and escape internal double quotes by doubling them.
- Add a UTF-8 BOM (\uFEFF) at the start of your CSV for Excel compatibility, especially when your data contains non-ASCII characters.
- Use ISO date format (YYYY-MM-DD) in CSV exports rather than localized formats so dates import correctly in any spreadsheet application.
- For large exports (100,000+ rows), warn users about the expected file size and time, or implement streaming with a server-side export endpoint.
- Include only necessary columns in exports — omit internal IDs, encrypted fields, or system metadata that users do not need in their spreadsheets.
- Add a timestamp to the export filename (users-export-2024-11-15.csv) so downloaded files do not overwrite each other.
- Use the built-in table export for simple cases; build a custom JS Query export only when you need specific formatting, column selection, or multi-page data.

## Frequently asked questions

### How do I export all rows in a Retool table with server-side pagination?

The built-in table export only downloads the current page. Create a separate SQL query with the same WHERE filters but without LIMIT/OFFSET, then trigger it in a JS Query export flow. The JS Query fetches all matching rows and passes them to utils.downloadFile() after building the CSV string.

### Why does my Retool CSV export show garbled characters in Excel?

Excel defaults to Windows-1252 encoding when opening CSV files unless a UTF-8 BOM is present. Add the BOM character at the start of your CSV string: const csv = '\uFEFF' + csvContent. Also use 'text/csv;charset=utf-8;' as the MIME type in utils.downloadFile().

### Can I export Retool table data to Excel format (.xlsx) instead of CSV?

Retool's utils.downloadFile() exports raw file content — it does not generate binary Excel files natively. For .xlsx exports, use a library like SheetJS (xlsx) loaded via Retool's Preloaded Scripts. This creates a more complex implementation; for most use cases, CSV with UTF-8 BOM opens cleanly in Excel.

### How do I include only specific columns in a Retool CSV export?

Define a columns array in your JS Query that specifies which fields to include: [{ key: 'id', header: 'ID' }, { key: 'email', header: 'Email' }]. Then map each row to only those columns. This is more reliable than relying on the built-in export which includes all visible table columns.

---

Source: https://www.rapidevelopers.com/retool-tutorial/how-to-export-data-from-retool-to-csv
© RapidDev — https://www.rapidevelopers.com/retool-tutorial/how-to-export-data-from-retool-to-csv
