Skip to main content
RapidDev - Software Development Agency
retool-tutorial

How to Handle File Downloads in Retool

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).

What you'll learn

  • Use utils.downloadFile(data, fileName, mimeType) to trigger browser file downloads
  • Generate and download CSV files from Retool query data using JavaScript
  • Download files from S3 or cloud storage using presigned URLs
  • Trigger downloads from button clicks or table row actions
  • Handle different MIME types for CSV, PDF, Excel, and image downloads
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Beginner8 min read15-20 minRetool Cloud and Self-hostedLast updated March 2026RapidDev Engineering Team
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).

Quick facts about this guide
FactValue
ToolRetool
DifficultyBeginner
Time required15-20 min
CompatibilityRetool Cloud and Self-hosted
Last updatedMarch 2026

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.

Prerequisites

  • 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.

typescript
1// JS Query: downloadTableAsCSV
2// Triggered by 'Export CSV' button click
3
4const rows = query1.data; // Array of row objects from your query
5
6if (!rows || rows.length === 0) {
7 utils.showNotification({
8 title: 'No data to export',
9 notificationType: 'warning',
10 });
11 return;
12}
13
14// Build CSV header from first row's keys
15const headers = Object.keys(rows[0]);
16const csvHeader = headers.map(h => `"${h}"`).join(',');
17
18// Build CSV rows — escape quotes in values
19const csvRows = rows.map(row =>
20 headers.map(h => {
21 const val = row[h];
22 if (val === null || val === undefined) return '';
23 const str = String(val).replace(/"/g, '""');
24 return `"${str}"`;
25 }).join(',')
26);
27
28const csvContent = [csvHeader, ...csvRows].join('\n');
29
30// Trigger download
31utils.downloadFile(csvContent, 'export.csv', 'text/csv');
32
33utils.showNotification({
34 title: 'Download started',
35 description: `Exporting ${rows.length} rows`,
36 notificationType: 'success',
37});

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().

typescript
1// Generate timestamped filename
2const now = new Date();
3const dateStr = now.toISOString().slice(0, 10); // '2026-03-30'
4const timeStr = now.toTimeString().slice(0, 5).replace(':', '-'); // '14-35'
5const filename = `orders_export_${dateStr}_${timeStr}.csv`;
6
7// Pass to downloadFile:
8utils.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).

typescript
1// JS Query: downloadPDF
2// getPDFData is a query that returns { pdf_base64: '...' }
3
4const pdfBase64 = getPDFData.data[0]?.pdf_base64;
5
6if (!pdfBase64) {
7 utils.showNotification({
8 title: 'PDF not available',
9 notificationType: 'error',
10 });
11 return;
12}
13
14// Download PDF
15utils.downloadFile(
16 pdfBase64,
17 `invoice_${table1.selectedRow.data.invoice_number}.pdf`,
18 'application/pdf'
19);
20
21// Common MIME types:
22// 'text/csv' — CSV
23// 'application/pdf' — PDF
24// 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' — .xlsx
25// 'application/json' — JSON
26// 'image/png' — PNG
27// '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.

typescript
1// JS Query: downloadFromS3
2// Calls a backend endpoint to get a presigned URL
3
4// Step 1: Get presigned URL from your API
5await getPresignedURL.trigger({
6 additionalScope: {
7 fileKey: table1.selectedRow.data.s3_key,
8 expiresIn: 300, // 5 minutes
9 },
10});
11
12const presignedUrl = getPresignedURL.data?.url;
13
14if (!presignedUrl) {
15 utils.showNotification({
16 title: 'Download failed',
17 description: 'Could not generate download URL.',
18 notificationType: 'error',
19 });
20 return;
21}
22
23// Step 2: Open the presigned URL — browser downloads directly from S3
24window.open(presignedUrl, '_blank');
25
26// Alternatively, for forced download (not browser preview):
27const a = document.createElement('a');
28a.href = presignedUrl;
29a.download = table1.selectedRow.data.filename;
30a.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.

typescript
1// JS Query: downloadFilteredData
2// Exports only records matching current filters
3
4// Re-run the query with current filters to get filtered data
5await getOrders.trigger();
6const filteredRows = getOrders.data;
7
8// Build CSV from filtered data (same CSV logic as step 1)
9const headers = ['id', 'order_number', 'customer_name', 'status', 'total_amount', 'created_at'];
10const csvHeader = headers.join(',');
11const csvRows = filteredRows.map(row =>
12 headers.map(h => `"${String(row[h] ?? '').replace(/"/g, '""')}"`).join(',')
13);
14
15const filename = `orders_filtered_${statusFilter.value || 'all'}_${new Date().toISOString().slice(0, 10)}.csv`;
16utils.downloadFile([csvHeader, ...csvRows].join('\n'), filename, 'text/csv');

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

Complete working example

JS Query: downloadTableAsCSV (full implementation)
1// JS Query: downloadTableAsCSV
2// Full implementation with column selection, formatting, and error handling
3// Triggered by 'Export CSV' button
4
5const rows = query1.data;
6
7if (!rows || rows.length === 0) {
8 utils.showNotification({
9 title: 'No data to export',
10 description: 'The table is empty. Apply different filters and try again.',
11 notificationType: 'warning',
12 });
13 return;
14}
15
16// Define which columns to include and their display names
17const columnConfig = [
18 { key: 'id', label: 'ID' },
19 { key: 'order_number', label: 'Order #' },
20 { key: 'customer_name', label: 'Customer' },
21 { key: 'status', label: 'Status' },
22 { key: 'total_amount', label: 'Total (USD)' },
23 { key: 'created_at', label: 'Created At' },
24];
25
26// Build CSV header
27const csvHeader = columnConfig.map(c => `"${c.label}"`).join(',');
28
29// Build CSV rows with formatting
30const csvRows = rows.map(row => {
31 return columnConfig.map(({ key }) => {
32 const val = row[key];
33 if (val === null || val === undefined) return '""';
34
35 // Format dates
36 if (key.includes('_at') && val) {
37 const formatted = new Date(val).toLocaleDateString('en-US');
38 return `"${formatted}"`;
39 }
40
41 // Format numbers
42 if (key === 'total_amount') {
43 return `"${parseFloat(val).toFixed(2)}"`;
44 }
45
46 // Escape quotes in string values
47 return `"${String(val).replace(/"/g, '""')}"`;
48 }).join(',');
49});
50
51// Combine and trigger download
52const csvContent = [csvHeader, ...csvRows].join('\n');
53const dateStr = new Date().toISOString().slice(0, 10);
54const filename = `orders_export_${dateStr}.csv`;
55
56utils.downloadFile(csvContent, filename, 'text/csv');
57
58utils.showNotification({
59 title: 'Export complete',
60 description: `Downloading ${rows.length} rows as ${filename}`,
61 notificationType: 'success',
62 duration: 4,
63});

Common mistakes when handling File Downloads in Retool

Why it's a problem: Using utils.downloadFile() to download large files (PDFs, images) from a query that returns Base64, causing browser memory issues

How to avoid: 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.

Why it's a problem: Not escaping commas and quotes in CSV values, producing corrupt CSV files

How to avoid: 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.

Why it's a problem: Trying to use utils.downloadFile() from an event handler directly instead of a JS Query

How to avoid: 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

Still stuck?

Copy one of these prompts to get a personalized, step-by-step explanation.

ChatGPT Prompt

I have a Retool Table showing orders from a SQL query 'getOrders'. I need a 'Download CSV' button that: (1) exports the visible rows as a CSV file, (2) uses column headers 'ID, Order #, Customer, Status, Amount, Date' (not the raw SQL column names), (3) formats the amount column to 2 decimal places, (4) formats the date column as MM/DD/YYYY, (5) includes the current filter values in the filename. Write the complete JS Query for the download button including CSV escaping.

Retool Prompt

Create Retool JS Query 'downloadCSV' for a Download button: access query1.data, build CSV with column mapping [{key:'id',label:'ID'}, {key:'order_number',label:'Order #'}], escape quotes with .replace(/"/g, '""'), call utils.downloadFile(csvContent, 'export_'+new Date().toISOString().slice(0,10)+'.csv', 'text/csv'). Show utils.showNotification with row count. Handle empty data case.

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Learning is great. Shipping is faster with help.

Our engineers have built 600+ apps on Retool and the tools around it. If your project needs to be live sooner than your learning curve allows — book a free consultation.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.