# How to Handle File Downloads in Retool

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

## TL;DR

Use utils.downloadFile(data, fileName, mimeType) in a JS Query to trigger a file download in the user's browser. For CSV: generate the CSV string from query data in JavaScript, then call utils.downloadFile(csvString, 'export.csv', 'text/csv'). For files in S3, generate a presigned URL on the backend and open it with window.open(presignedUrl).

## Triggering File Downloads in Retool Apps

Retool provides the utils.downloadFile() utility function for triggering browser downloads from within JS Queries. You can generate file content in JavaScript (for text-based formats like CSV) or provide Base64-encoded binary data (for PDFs, images, Excel files).

For files stored in cloud storage (S3, Supabase Storage, Google Cloud Storage), the better approach is to generate a presigned URL on your backend and open it with window.open() — this streams the file directly from storage without going through Retool's servers.

This tutorial covers both patterns with working code examples.

## Before you start

- A Retool app with a query returning data to export
- Basic JavaScript knowledge (array methods, string operations)
- For S3 downloads: a backend endpoint or Edge Function that generates presigned URLs

## Step-by-step guide

### 1. Download a simple CSV from query data

The most common download use case: export a table's data as a CSV. In a JS Query triggered by a 'Download CSV' button, access the query data, convert it to CSV format, and call utils.downloadFile(). The function signature is utils.downloadFile(data, fileName, fileType) where data is the file content string.

```
// JS Query: downloadTableAsCSV
// Triggered by 'Export CSV' button click

const rows = query1.data; // Array of row objects from your query

if (!rows || rows.length === 0) {
  utils.showNotification({
    title: 'No data to export',
    notificationType: 'warning',
  });
  return;
}

// Build CSV header from first row's keys
const headers = Object.keys(rows[0]);
const csvHeader = headers.map(h => `"${h}"`).join(',');

// Build CSV rows — escape quotes in values
const csvRows = rows.map(row =>
  headers.map(h => {
    const val = row[h];
    if (val === null || val === undefined) return '';
    const str = String(val).replace(/"/g, '""');
    return `"${str}"`;
  }).join(',')
);

const csvContent = [csvHeader, ...csvRows].join('\n');

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

utils.showNotification({
  title: 'Download started',
  description: `Exporting ${rows.length} rows`,
  notificationType: 'success',
});
```

**Expected result:** Browser prompts user to save or opens a file download for 'export.csv' with table data.

### 2. Add a date suffix to the exported file name

For regularly exported files, include a timestamp in the filename so multiple exports don't overwrite each other. Use JavaScript's Date object to generate a formatted timestamp. Pass the dynamic filename to utils.downloadFile().

```
// Generate timestamped filename
const now = new Date();
const dateStr = now.toISOString().slice(0, 10); // '2026-03-30'
const timeStr = now.toTimeString().slice(0, 5).replace(':', '-'); // '14-35'
const filename = `orders_export_${dateStr}_${timeStr}.csv`;

// Pass to downloadFile:
utils.downloadFile(csvContent, filename, 'text/csv');
```

**Expected result:** Downloaded file has a unique timestamped name like 'orders_export_2026-03-30_14-35.csv'.

### 3. Download a PDF or binary file from Base64 data

For PDF or binary file downloads, utils.downloadFile() also accepts Base64-encoded content. Use the correct MIME type for the file format. The data parameter should be the raw Base64 string (without the data:mime/type;base64, prefix — Retool handles that internally).

```
// JS Query: downloadPDF
// getPDFData is a query that returns { pdf_base64: '...' }

const pdfBase64 = getPDFData.data[0]?.pdf_base64;

if (!pdfBase64) {
  utils.showNotification({
    title: 'PDF not available',
    notificationType: 'error',
  });
  return;
}

// Download PDF
utils.downloadFile(
  pdfBase64,
  `invoice_${table1.selectedRow.data.invoice_number}.pdf`,
  'application/pdf'
);

// Common MIME types:
// 'text/csv' — CSV
// 'application/pdf' — PDF
// 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' — .xlsx
// 'application/json' — JSON
// 'image/png' — PNG
// 'image/jpeg' — JPEG
```

**Expected result:** PDF file downloads with the correct invoice filename.

### 4. Download files from S3 using presigned URLs

For large files stored in S3 or cloud storage, streaming through utils.downloadFile() is inefficient — it would load the entire file into the browser's memory. Instead, generate a presigned S3 URL from a server-side query or Edge Function, then open it directly with window.open(url). The browser streams the file directly from S3. The presigned URL expires after a set time (e.g., 5 minutes) for security.

```
// JS Query: downloadFromS3
// Calls a backend endpoint to get a presigned URL

// Step 1: Get presigned URL from your API
await getPresignedURL.trigger({
  additionalScope: {
    fileKey: table1.selectedRow.data.s3_key,
    expiresIn: 300, // 5 minutes
  },
});

const presignedUrl = getPresignedURL.data?.url;

if (!presignedUrl) {
  utils.showNotification({
    title: 'Download failed',
    description: 'Could not generate download URL.',
    notificationType: 'error',
  });
  return;
}

// Step 2: Open the presigned URL — browser downloads directly from S3
window.open(presignedUrl, '_blank');

// Alternatively, for forced download (not browser preview):
const a = document.createElement('a');
a.href = presignedUrl;
a.download = table1.selectedRow.data.filename;
a.click();
```

**Expected result:** Browser opens the S3 file directly via presigned URL. Large files download from cloud storage without loading into Retool memory.

### 5. Download filtered data based on current table state

Often users want to download only the currently filtered/visible data, not all records. Reference the table's current filter state or the query that drives the table (with its current parameters). This ensures the export matches what the user sees on screen.

```
// JS Query: downloadFilteredData
// Exports only records matching current filters

// Re-run the query with current filters to get filtered data
await getOrders.trigger();
const filteredRows = getOrders.data;

// Build CSV from filtered data (same CSV logic as step 1)
const headers = ['id', 'order_number', 'customer_name', 'status', 'total_amount', 'created_at'];
const csvHeader = headers.join(',');
const csvRows = filteredRows.map(row =>
  headers.map(h => `"${String(row[h] ?? '').replace(/"/g, '""')}"`).join(',')
);

const filename = `orders_filtered_${statusFilter.value || 'all'}_${new Date().toISOString().slice(0, 10)}.csv`;
utils.downloadFile([csvHeader, ...csvRows].join('\n'), filename, 'text/csv');
```

**Expected result:** Downloaded CSV contains only the records matching the current filter state.

## Complete code example

File: `JS Query: downloadTableAsCSV (full implementation)`

```javascript
// JS Query: downloadTableAsCSV
// Full implementation with column selection, formatting, and error handling
// Triggered by 'Export CSV' button

const rows = query1.data;

if (!rows || rows.length === 0) {
  utils.showNotification({
    title: 'No data to export',
    description: 'The table is empty. Apply different filters and try again.',
    notificationType: 'warning',
  });
  return;
}

// Define which columns to include and their display names
const columnConfig = [
  { key: 'id', label: 'ID' },
  { key: 'order_number', label: 'Order #' },
  { key: 'customer_name', label: 'Customer' },
  { key: 'status', label: 'Status' },
  { key: 'total_amount', label: 'Total (USD)' },
  { key: 'created_at', label: 'Created At' },
];

// Build CSV header
const csvHeader = columnConfig.map(c => `"${c.label}"`).join(',');

// Build CSV rows with formatting
const csvRows = rows.map(row => {
  return columnConfig.map(({ key }) => {
    const val = row[key];
    if (val === null || val === undefined) return '""';

    // Format dates
    if (key.includes('_at') && val) {
      const formatted = new Date(val).toLocaleDateString('en-US');
      return `"${formatted}"`;
    }

    // Format numbers
    if (key === 'total_amount') {
      return `"${parseFloat(val).toFixed(2)}"`;
    }

    // Escape quotes in string values
    return `"${String(val).replace(/"/g, '""')}"`;
  }).join(',');
});

// Combine and trigger download
const csvContent = [csvHeader, ...csvRows].join('\n');
const dateStr = new Date().toISOString().slice(0, 10);
const filename = `orders_export_${dateStr}.csv`;

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

utils.showNotification({
  title: 'Export complete',
  description: `Downloading ${rows.length} rows as ${filename}`,
  notificationType: 'success',
  duration: 4,
});
```

## Common mistakes

- **Using utils.downloadFile() to download large files (PDFs, images) from a query that returns Base64, causing browser memory issues** — undefined Fix: For files stored in cloud storage, generate a presigned URL and use window.open(url) to stream directly from storage. Only use utils.downloadFile() for files under ~10MB or dynamically-generated text/CSV content.
- **Not escaping commas and quotes in CSV values, producing corrupt CSV files** — undefined Fix: Wrap every CSV field value in double quotes and escape any double quotes within the value by doubling them ('He said "hello"' becomes '"He said ""hello"""'). The complete_code example in this tutorial handles this correctly.
- **Trying to use utils.downloadFile() from an event handler directly instead of a JS Query** — undefined Fix: utils.downloadFile() must be called from within a JS Query — it cannot be invoked directly from a button's event handler without a JS Query intermediary. Create a JS Query that calls utils.downloadFile() and trigger that query from the button.

## Best practices

- Always show a notification when a download starts — browser download progress is not visible in the Retool UI
- For large datasets (>10,000 rows), trigger a server-side export and send via email or generate a download link — don't load all data into the browser
- Use presigned S3 URLs for files stored in cloud storage — streaming through utils.downloadFile() is inefficient for large binary files
- Sanitize CSV data by escaping quotes and commas — values containing commas or quotes will corrupt the CSV structure
- Include a timestamp in exported filenames to prevent overwriting previous exports
- Show the row count in the notification ('Downloading 1,234 rows') so users know what to expect

## Frequently asked questions

### What is the maximum file size that utils.downloadFile() can handle?

utils.downloadFile() creates an in-memory Blob in the browser. The practical limit depends on the user's browser and available memory — files under 50MB generally work fine. For files over 50MB, use cloud storage presigned URLs instead of loading the file content through Retool.

### Can I generate an Excel (.xlsx) file download in Retool?

Yes — but it requires the SheetJS library (xlsx) which you can load via Preloaded JavaScript in app settings. Once loaded, use XLSX.write() to generate an ArrayBuffer, then pass it to utils.downloadFile() with MIME type 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'. See the SheetJS documentation for the generation code.

### How do I download a file that requires authentication headers?

If the file is behind an authenticated API (requiring Authorization headers), you cannot use window.open() directly since it doesn't support custom headers. Instead: (1) make the API call from a Retool resource query to fetch the file as Base64, then use utils.downloadFile() with the Base64 data, or (2) generate a temporary presigned URL on the server that doesn't require auth headers.

---

Source: https://www.rapidevelopers.com/retool-tutorial/how-to-handle-file-downloads-in-retool
© RapidDev — https://www.rapidevelopers.com/retool-tutorial/how-to-handle-file-downloads-in-retool
