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

How to Write SQL Queries in Retool

In Retool, write SQL queries in the Code panel after connecting a database resource. Use {{ componentName.value }} syntax to safely pass component values as parameterized inputs — Retool handles SQL injection prevention automatically. Queries run automatically when inputs change (if set to Automatic trigger) or on demand when triggered by event handlers.

What you'll learn

  • How to create SQL resource queries and connect them to database resources
  • How to use {{ }} parameterized bindings to safely pass component values into SQL
  • How to build dynamic WHERE clauses with optional filters from UI components
  • How to write INSERT, UPDATE, and DELETE queries triggered from form submissions
  • How to use JOINs and aggregate functions in Retool SQL queries
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Beginner9 min read20-25 minRetool Cloud and Self-hostedLast updated March 2026RapidDev Engineering Team
TL;DR

In Retool, write SQL queries in the Code panel after connecting a database resource. Use {{ componentName.value }} syntax to safely pass component values as parameterized inputs — Retool handles SQL injection prevention automatically. Queries run automatically when inputs change (if set to Automatic trigger) or on demand when triggered by event handlers.

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

SQL Queries in Retool: From Basics to Dynamic Filtering

SQL resource queries are the most common query type in Retool. They connect directly to a database resource (PostgreSQL, MySQL, MSSQL, Oracle, Snowflake, etc.) and run SQL that your browser sends to Retool's backend, which executes it against your database and returns the results.

Retool uses parameterized queries automatically when you write {{ componentValue }} in your SQL — the value is passed as a bound parameter, not string-interpolated. This prevents SQL injection and handles type conversion correctly.

This tutorial covers the full lifecycle of SQL queries in Retool: creating and running SELECT queries, adding dynamic filters from form components, writing mutation queries (INSERT/UPDATE/DELETE) with proper parameterization, and using JOINs for relational data.

Prerequisites

  • A Retool account with at least one database resource configured (Settings → Resources)
  • Basic SQL knowledge (SELECT, WHERE, JOIN, INSERT, UPDATE, DELETE)
  • A Retool app to write queries in
  • Database credentials and access to a test database (not production for learning)

Step-by-step guide

1

Create a new SQL query in the Code panel

In the Retool editor, open the Code panel from the left sidebar. Click + to add a new query. In the Resource dropdown, select your database resource (PostgreSQL, MySQL, etc.). Give the query a descriptive name like getOrders or searchCustomers. The query editor opens with a SQL text area. By default, the trigger is set to Automatic, which runs the query on page load and whenever its {{ }} inputs change.

Expected result: A new SQL query is created and connected to your database resource. The editor shows a SQL text area.

2

Write a basic SELECT query with {{ }} parameter binding

Write your SQL in the query editor. Use {{ componentName.property }} to reference component values safely. Retool automatically parameterizes these values — they are passed as bound parameters to the database driver, preventing SQL injection. You can reference any component value: textInput1.value, table1.selectedRow.data.id, select1.value, datePicker1.value, state1.value, etc.

typescript
1-- Basic SELECT with a single filter
2SELECT id, name, email, status, created_at
3FROM customers
4WHERE status = {{ statusFilter.value || 'active' }}
5ORDER BY name ASC
6LIMIT 100;
7
8-- With multiple filters:
9SELECT o.id, o.total_amount, o.status, c.name as customer_name
10FROM orders o
11JOIN customers c ON c.id = o.customer_id
12WHERE o.status = {{ orderStatusSelect.value }}
13AND o.created_at >= {{ startDate.value }}
14AND o.created_at <= {{ endDate.value }}
15ORDER BY o.created_at DESC;

Expected result: The query runs and returns data matching the component filter values. Changing the filter component value re-runs the query automatically.

3

Build optional dynamic WHERE clauses

A common pattern is optional filters — the query returns all rows when a filter is empty, and filtered rows when a value is selected. Use conditional expressions in the WHERE clause to make filters optional. The pattern 1=1 allows adding AND clauses without special-casing the first condition.

typescript
1-- Optional filters pattern
2-- Returns all orders if filters are empty
3-- Applies filters only when they have values
4SELECT o.id, o.total_amount, o.status, c.name as customer_name
5FROM orders o
6JOIN customers c ON c.id = o.customer_id
7WHERE 1=1
8 AND ({{ statusSelect.value === '' }} OR o.status = {{ statusSelect.value }})
9 AND ({{ searchInput.value === '' }} OR c.name ILIKE {{ '%' + searchInput.value + '%' }})
10 AND ({{ !startDate.value }} OR o.created_at >= {{ startDate.value }})
11 AND ({{ !endDate.value }} OR o.created_at <= {{ endDate.value }})
12ORDER BY o.created_at DESC
13LIMIT {{ table1.pageSize || 50 }}
14OFFSET {{ table1.paginationOffset || 0 }};

Expected result: When filters are empty, all rows return. When filters have values, results are narrowed. All filter combinations work correctly.

4

Write INSERT queries for form submissions

Write INSERT queries triggered by form submission events. Reference form field component values using {{ }} parameterized bindings. Set the query trigger to Manual (it should only run when the form is submitted, not automatically on page load). Trigger it from the form's Submit button event handler.

typescript
1-- INSERT query: createCustomer
2-- Trigger: Manual (run from Submit button event handler)
3INSERT INTO customers (name, email, phone, status, created_at)
4VALUES (
5 {{ nameInput.value }},
6 {{ emailInput.value }},
7 {{ phoneInput.value || null }},
8 {{ statusSelect.value || 'active' }},
9 NOW()
10)
11RETURNING id, name, email, created_at;
12
13-- UPSERT pattern (create or update):
14INSERT INTO customers (id, name, email, status, updated_at)
15VALUES (
16 {{ editingId.value || gen_random_uuid() }},
17 {{ nameInput.value }},
18 {{ emailInput.value }},
19 {{ statusSelect.value }},
20 NOW()
21)
22ON CONFLICT (id) DO UPDATE
23 SET name = EXCLUDED.name,
24 email = EXCLUDED.email,
25 status = EXCLUDED.status,
26 updated_at = NOW();

Expected result: Clicking Submit triggers the INSERT query, creates a new record, and returns the new row's data.

5

Write UPDATE and DELETE queries with row selection

UPDATE and DELETE queries typically operate on the row selected in a Table component. Reference table1.selectedRow.data.id (or any column) in the WHERE clause. Set these queries to Manual trigger. Always include a specific WHERE clause — a DELETE without WHERE deletes all rows.

typescript
1-- UPDATE query: updateCustomerStatus
2-- Trigger: Manual
3UPDATE customers
4SET
5 status = {{ statusSelect.value }},
6 updated_at = NOW()
7WHERE id = {{ table1.selectedRow.data.id }}
8RETURNING id, name, status, updated_at;
9
10-- DELETE query: deleteCustomer
11-- Trigger: Manual
12-- CRITICAL: Always include WHERE with a specific ID
13DELETE FROM customers
14WHERE id = {{ table1.selectedRow.data.id }}
15RETURNING id;
16
17-- Soft delete (preferred over hard delete):
18UPDATE customers
19SET deleted_at = NOW(), status = 'deleted'
20WHERE id = {{ table1.selectedRow.data.id }};

Expected result: The UPDATE or DELETE query modifies only the selected row. After triggering, the table refreshes and reflects the change.

6

Use JOINs and aggregates for relational data

Retool SQL queries support the full SQL feature set of your database. Write multi-table JOINs, aggregate functions (COUNT, SUM, AVG), subqueries, CTEs, and window functions. The result set is returned as an array of objects where each key is a column name, available as query1.data.

typescript
1-- Complex JOIN with aggregates for a dashboard KPI
2SELECT
3 c.id,
4 c.name,
5 c.email,
6 c.status,
7 COUNT(o.id) AS order_count,
8 COALESCE(SUM(o.total_amount), 0) AS total_spent,
9 MAX(o.created_at) AS last_order_date,
10 ROUND(COALESCE(AVG(o.total_amount), 0), 2) AS avg_order_value
11FROM customers c
12LEFT JOIN orders o ON o.customer_id = c.id
13 AND o.status != 'cancelled'
14WHERE c.status = 'active'
15GROUP BY c.id, c.name, c.email, c.status
16HAVING COUNT(o.id) >= {{ minOrdersInput.value || 0 }}
17ORDER BY total_spent DESC
18LIMIT 100;

Expected result: The query returns a combined result with customer data and their order statistics in a single result set.

Complete working example

SQL Query: fullCRUDQuerySet
1-- === Query 1: getCustomers (SELECT with optional filters) ===
2SELECT
3 c.id, c.name, c.email, c.status, c.created_at,
4 COUNT(o.id) AS order_count
5FROM customers c
6LEFT JOIN orders o ON o.customer_id = c.id
7WHERE 1=1
8 AND ({{ searchInput.value === '' || !searchInput.value }}
9 OR c.name ILIKE {{ '%' + searchInput.value + '%' }}
10 OR c.email ILIKE {{ '%' + searchInput.value + '%' }})
11 AND ({{ statusFilter.value === '' || !statusFilter.value }}
12 OR c.status = {{ statusFilter.value }})
13GROUP BY c.id
14ORDER BY c.created_at DESC
15LIMIT {{ table1.pageSize || 50 }}
16OFFSET {{ table1.paginationOffset || 0 }};
17
18-- === Query 2: createCustomer (INSERT) ===
19INSERT INTO customers (name, email, phone, company, status, created_at)
20VALUES (
21 {{ nameInput.value }},
22 {{ emailInput.value }},
23 {{ phoneInput.value || null }},
24 {{ companyInput.value || null }},
25 'active',
26 NOW()
27)
28RETURNING *;
29
30-- === Query 3: updateCustomer (UPDATE) ===
31UPDATE customers
32SET
33 name = {{ nameInput.value }},
34 email = {{ emailInput.value }},
35 phone = {{ phoneInput.value || null }},
36 company = {{ companyInput.value || null }},
37 updated_at = NOW()
38WHERE id = {{ table1.selectedRow.data.id }}
39RETURNING *;
40
41-- === Query 4: deleteCustomer (soft DELETE) ===
42UPDATE customers
43SET
44 status = 'deleted',
45 deleted_at = NOW()
46WHERE id = {{ table1.selectedRow.data.id }}
47AND status != 'deleted' -- prevent double-delete
48RETURNING id, name;

Common mistakes

Why it's a problem: Writing {{ 'string' + variable }} string concatenation in SQL instead of parameterized bindings

How to avoid: Use {{ '%' + searchInput.value + '%' }} for the ILIKE value — Retool sends this as a parameterized value, not raw string interpolation. This is safe from SQL injection.

Why it's a problem: Setting mutation queries (INSERT/UPDATE/DELETE) to Automatic trigger

How to avoid: Change mutation query triggers to Manual. Automatic trigger runs queries whenever inputs change — an INSERT set to Automatic will fire every time a form field changes, creating duplicate records.

Why it's a problem: Writing a DELETE query without a WHERE clause in testing

How to avoid: Never run DELETE without WHERE in a production database. Always test with a RETURNING clause to preview what would be deleted before removing the WHERE.

Why it's a problem: Referencing table1.selectedRow.data.id in a query that runs automatically — when no row is selected, the value is undefined

How to avoid: Either set the query to Manual trigger, or add a guard: AND ({{ !table1.selectedRow.data }} OR id = {{ table1.selectedRow.data?.id }}) to handle the null case.

Best practices

  • Always use {{ }} parameterized bindings for user-supplied values — never string-concatenate values into SQL to prevent SQL injection.
  • Provide default values for optional filter bindings: {{ statusFilter.value || 'active' }} prevents null parameters from breaking queries.
  • Set mutation queries (INSERT, UPDATE, DELETE) to Manual trigger — they should only run when explicitly triggered, not automatically on page load.
  • Use RETURNING (PostgreSQL) or similar to get back the modified row's data, useful for refreshing UI state after mutations.
  • Add a success event handler on mutation queries to automatically refresh the SELECT query that populates the table.
  • Use soft deletes (deleted_at timestamp) instead of hard DELETE for any business-critical data to enable recovery.
  • Test SQL queries with LIMIT 5 first when working with large tables to verify the query structure before running without a limit.

Still stuck?

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

ChatGPT Prompt

I am building a Retool CRM app connected to a PostgreSQL database. Show me: (1) a SELECT query with optional filters for status (dropdown) and search text (text input) using {{ }} parameterized bindings, (2) an INSERT query for a new customer form with {{ }} references to form fields, (3) an UPDATE query that uses the table's selected row ID, and (4) a soft DELETE query that sets deleted_at instead of removing the row. Explain the parameterized binding syntax and how Retool prevents SQL injection.

Retool Prompt

In my Retool app, write a SQL query named 'searchCustomers' that: (1) selects from a customers table with columns id, name, email, status, created_at, (2) filters by name ILIKE when searchInput.value is not empty, (3) filters by status = statusSelect.value when it has a value, (4) uses 1=1 AND optional clause pattern, (5) adds LIMIT {{ table1.pageSize || 50 }} and OFFSET {{ table1.paginationOffset || 0 }} for pagination.

Frequently asked questions

Does Retool automatically prevent SQL injection when using {{ }} bindings?

Yes. When you use {{ componentName.value }} in a Retool SQL query, the value is passed as a parameterized bound variable to the database driver — it is never string-interpolated into the SQL text. This means SQL injection is not possible through {{ }} bindings. Never manually build SQL strings by concatenating user input.

Can I use stored procedures or functions in Retool SQL queries?

Yes. Retool passes your SQL directly to the database, so any SQL supported by your database works — including CALL storedProcedure({{ param }}) for PostgreSQL/MySQL, EXEC for MSSQL, and any database-specific function calls. Retool does not restrict or modify your SQL syntax.

Why does my Retool SQL query run automatically when I change a form field, even though I did not click any button?

Queries set to 'Automatic' trigger re-run whenever any of their {{ }} input bindings change. If your INSERT or UPDATE query references a form field and is set to Automatic, it fires on every keystroke. Change the trigger to 'Manual' for mutation queries and run them explicitly from button event handlers.

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.