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).
| Fact | Value |
|---|---|
| Tool | Retool |
| Difficulty | Beginner |
| Time required | 15-20 min |
| Compatibility | Retool Cloud and Self-hosted |
| Last updated | March 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
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.
1// JS Query: downloadTableAsCSV2// Triggered by 'Export CSV' button click34const rows = query1.data; // Array of row objects from your query56if (!rows || rows.length === 0) {7 utils.showNotification({8 title: 'No data to export',9 notificationType: 'warning',10 });11 return;12}1314// Build CSV header from first row's keys15const headers = Object.keys(rows[0]);16const csvHeader = headers.map(h => `"${h}"`).join(',');1718// Build CSV rows — escape quotes in values19const 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);2728const csvContent = [csvHeader, ...csvRows].join('\n');2930// Trigger download31utils.downloadFile(csvContent, 'export.csv', 'text/csv');3233utils.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.
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().
1// Generate timestamped filename2const 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`;67// 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'.
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).
1// JS Query: downloadPDF2// getPDFData is a query that returns { pdf_base64: '...' }34const pdfBase64 = getPDFData.data[0]?.pdf_base64;56if (!pdfBase64) {7 utils.showNotification({8 title: 'PDF not available',9 notificationType: 'error',10 });11 return;12}1314// Download PDF15utils.downloadFile(16 pdfBase64,17 `invoice_${table1.selectedRow.data.invoice_number}.pdf`,18 'application/pdf'19);2021// Common MIME types:22// 'text/csv' — CSV23// 'application/pdf' — PDF24// 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' — .xlsx25// 'application/json' — JSON26// 'image/png' — PNG27// 'image/jpeg' — JPEGExpected result: PDF file downloads with the correct invoice filename.
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.
1// JS Query: downloadFromS32// Calls a backend endpoint to get a presigned URL34// Step 1: Get presigned URL from your API5await getPresignedURL.trigger({6 additionalScope: {7 fileKey: table1.selectedRow.data.s3_key,8 expiresIn: 300, // 5 minutes9 },10});1112const presignedUrl = getPresignedURL.data?.url;1314if (!presignedUrl) {15 utils.showNotification({16 title: 'Download failed',17 description: 'Could not generate download URL.',18 notificationType: 'error',19 });20 return;21}2223// Step 2: Open the presigned URL — browser downloads directly from S324window.open(presignedUrl, '_blank');2526// 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.
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.
1// JS Query: downloadFilteredData2// Exports only records matching current filters34// Re-run the query with current filters to get filtered data5await getOrders.trigger();6const filteredRows = getOrders.data;78// 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);1415const 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
1// JS Query: downloadTableAsCSV2// Full implementation with column selection, formatting, and error handling3// Triggered by 'Export CSV' button45const rows = query1.data;67if (!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}1516// Define which columns to include and their display names17const 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];2526// Build CSV header27const csvHeader = columnConfig.map(c => `"${c.label}"`).join(',');2829// Build CSV rows with formatting30const csvRows = rows.map(row => {31 return columnConfig.map(({ key }) => {32 const val = row[key];33 if (val === null || val === undefined) return '""';3435 // Format dates36 if (key.includes('_at') && val) {37 const formatted = new Date(val).toLocaleDateString('en-US');38 return `"${formatted}"`;39 }4041 // Format numbers42 if (key === 'total_amount') {43 return `"${parseFloat(val).toFixed(2)}"`;44 }4546 // Escape quotes in string values47 return `"${String(val).replace(/"/g, '""')}"`;48 }).join(',');49});5051// Combine and trigger download52const csvContent = [csvHeader, ...csvRows].join('\n');53const dateStr = new Date().toISOString().slice(0, 10);54const filename = `orders_export_${dateStr}.csv`;5556utils.downloadFile(csvContent, filename, 'text/csv');5758utils.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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation