# How to Handle Errors in Retool Queries

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

## TL;DR

Handle Retool query errors in two places: the query's Failure event handler (in query settings, fires after the query fails) and try/catch blocks in JS Queries. Use utils.showNotification() to show user-friendly error messages and reference {{ queryName.error.message }} for the actual error detail. Every production app should handle failures — silent errors confuse users and hide bugs.

## Error Handling Strategies for Retool Queries

Without error handling, failed queries in Retool produce no user feedback — the app just silently stops working. A user clicks Submit and nothing happens, with no indication of whether the form was saved, failed, or is still processing.

Retool provides two error handling mechanisms. The first is the query's Failure event handler, configured in the query settings. This fires whenever the query fails and is perfect for showing notifications or resetting state. The second is try/catch blocks inside JS Queries, which give you programmatic control to branch on errors, retry logic, or clean up state.

This tutorial covers both patterns, shows how to display meaningful error messages with query error properties, and demonstrates a global error handler pattern for production apps.

## Before you start

- A Retool account with an app containing at least one SQL or REST API query
- Basic JavaScript knowledge (try/catch, async/await)
- Understanding of JS Queries and event handlers in Retool

## Step-by-step guide

### 1. Add a Failure event handler to a query

Select the query in the Code panel. In the query's settings (gear icon or Settings tab), find the 'Event handlers' section. Click + to add a new event handler. Set the Event to 'Failure' (as opposed to 'Success'). Set the Action to 'Show notification'. In the notification title, you can reference the query's error using {{ updateRecord.error.message }}. This handler fires whenever the query fails — network errors, SQL errors, validation failures, etc.

```
// Failure event handler configuration:
// Event: Failure
// Action: Show notification
// Notification type: Error
// Title: Update failed
// Description: {{ updateRecord.error.message || 'An unexpected error occurred' }}

// To show a more user-friendly message, use a ternary:
// Title: {{ updateRecord.error.message?.includes('unique constraint') ? 'Email already exists' : 'Save failed' }}
// Description: {{ updateRecord.error.message }}
```

**Expected result:** When the query fails, a red error notification appears with the error message. The user knows the action failed.

### 2. Reference query error properties in the UI

When a query fails, its .error property is populated. You can reference this in any {{ }} binding. Use queryName.error to get the full error object, queryName.error.message for the error text, and queryName.isFetching to check if the query is currently running. Display these in Text components or conditional banners for inline error feedback.

```
// Show an error banner below a form:
// Text component value:
{{ updateRecord.error ? `Error: ${updateRecord.error.message}` : '' }}

// Show/hide the error banner:
// Container Hidden property:
{{ !updateRecord.error }}

// Disable a submit button while fetching or after error:
// Button Disabled property:
{{ updateRecord.isFetching }}

// Show query state in a Text component for debugging:
{{ updateRecord.isFetching ? 'Saving...' : updateRecord.error ? 'Failed' : updateRecord.data ? 'Saved' : 'Ready' }}
```

**Expected result:** The UI shows real-time query state: loading indicator while fetching, error message on failure, success state on completion.

### 3. Use try/catch in JS Queries for programmatic error handling

In JS Queries that trigger other queries, wrap async operations in try/catch to handle errors programmatically. This is necessary when you need to clean up state after an error (e.g., reset a loading indicator), implement retry logic, or show different messages based on the error type. Always use a finally block to reset loading state variables so the UI never gets stuck in a loading state.

```
// JS Query: submitFormWithErrorHandling
const isEditing = !!editingId.value;

await isSaving.setValue(true);

try {
  if (isEditing) {
    await updateRecord.trigger();
  } else {
    await createRecord.trigger();
  }
  
  // Success path
  utils.showNotification({
    title: isEditing ? 'Record updated' : 'Record created',
    notificationType: 'success',
    duration: 3000
  });
  
  await modal1.close();
  await getRecords.trigger(); // Refresh table
  
} catch (err) {
  // Error path — err contains the query failure details
  const message = err?.message || 'Unknown error';
  console.error('Submit failed:', message, err);
  
  // Show user-friendly message based on error type
  let userMessage = message;
  if (message.includes('unique constraint') || message.includes('duplicate key')) {
    userMessage = 'A record with this email already exists.';
  } else if (message.includes('foreign key')) {
    userMessage = 'Cannot save — referenced record does not exist.';
  } else if (message.includes('not null')) {
    userMessage = 'A required field is missing.';
  }
  
  utils.showNotification({
    title: 'Save failed',
    description: userMessage,
    notificationType: 'error',
    duration: 8000
  });
  
} finally {
  // Always runs — prevents stuck loading state
  await isSaving.setValue(false);
}
```

**Expected result:** On success, the form closes and the table refreshes. On error, the modal stays open with an error notification and the loading state clears.

### 4. Handle validation errors before triggering queries

The best error is one that never reaches the database. Validate inputs in a JS Query before triggering write queries. Return early with a notification if inputs are invalid. This prevents unnecessary database roundtrips and provides faster feedback than waiting for a database constraint violation.

```
// JS Query: validateAndSubmit
const name = nameInput.value?.trim();
const email = emailInput.value?.trim();
const budget = parseFloat(budgetInput.value);

// Client-side validation (fast feedback, no DB roundtrip)
const errors = [];

if (!name) errors.push('Name is required');
if (!email || !email.includes('@')) errors.push('Valid email is required');
if (isNaN(budget) || budget < 0) errors.push('Budget must be a positive number');

if (errors.length > 0) {
  utils.showNotification({
    title: 'Please fix the following:',
    description: errors.join('. '),
    notificationType: 'warning',
    duration: 5000
  });
  return; // Stop here — do not proceed to DB query
}

// Inputs are valid — proceed to DB query
try {
  await createProject.trigger({
    additionalScope: { name, email, budget }
  });
  utils.showNotification({ title: 'Project created', notificationType: 'success' });
} catch (err) {
  utils.showNotification({ title: 'Failed to create', description: err.message, notificationType: 'error' });
}
```

**Expected result:** Invalid inputs produce immediate validation messages without any DB query. Valid inputs proceed to the database with proper error handling.

### 5. Build a reusable error handler helper

In large apps with many queries, create a consistent error handling pattern. You can define a helper function at the top of a JS Query that standardizes how errors are logged and displayed. This reduces duplication and ensures every error follows the same format in production.

```
// JS Query: globalQueryHelper
// This is a standalone JS Query that other queries can use
// by calling await globalQueryHelper.trigger() — but typically
// you define the handler inline in each query.

// Inline helper pattern (define once at top of each JS Query):
function handleError(err, queryName) {
  const message = err?.message || 'An unexpected error occurred';
  console.error(`[${queryName}] Error:`, message, err);
  
  // Map common DB errors to user-friendly messages
  const userMessages = {
    'unique constraint': 'This record already exists.',
    'foreign key': 'Referenced record not found.',
    'not null': 'A required field is empty.',
    'permission denied': 'You do not have permission for this action.',
    'timeout': 'The request timed out. Please try again.'
  };
  
  const userMessage = Object.entries(userMessages)
    .find(([key]) => message.toLowerCase().includes(key))?.[1]
    || message;
  
  utils.showNotification({
    title: 'Operation failed',
    description: userMessage,
    notificationType: 'error',
    duration: 6000
  });
}

// Usage in any JS Query:
try {
  await myQuery.trigger();
} catch (err) {
  handleError(err, 'myQuery');
}
```

**Expected result:** All queries in the app produce consistent, user-friendly error messages mapped from technical database errors.

## Complete code example

File: `JS Query: robustCRUDHandler`

```javascript
// Complete CRUD handler with comprehensive error handling
// Handles create, update, and delete with specific error messages

const action = actionSelect.value; // 'create' | 'update' | 'delete'
const selected = table1.selectedRow.data;

// === Input validation ===
if (action === 'update' || action === 'delete') {
  if (!selected?.id) {
    utils.showNotification({
      title: 'Select a record first',
      notificationType: 'warning'
    });
    return;
  }
}

if (action === 'create' || action === 'update') {
  if (!nameInput.value?.trim()) {
    utils.showNotification({ title: 'Name is required', notificationType: 'warning' });
    return;
  }
}

// === Set loading state ===
await isProcessing.setValue(true);

try {
  switch (action) {
    case 'create':
      await createRecord.trigger({
        additionalScope: {
          name: nameInput.value.trim(),
          email: emailInput.value.trim(),
          status: 'active'
        }
      });
      utils.showNotification({ title: 'Record created', notificationType: 'success' });
      break;
      
    case 'update':
      await updateRecord.trigger({
        additionalScope: {
          id: selected.id,
          name: nameInput.value.trim(),
          email: emailInput.value.trim()
        }
      });
      utils.showNotification({ title: 'Record updated', notificationType: 'success' });
      break;
      
    case 'delete':
      await deleteRecord.trigger({
        additionalScope: { id: selected.id }
      });
      utils.showNotification({ title: 'Record deleted', notificationType: 'success' });
      break;
  }
  
  // Refresh and close modal
  await getRecords.trigger();
  await modal1.close();
  
} catch (err) {
  const msg = err?.message || 'Unknown error';
  console.error(`[${action}] failed:`, msg);
  
  let userMsg = msg;
  if (msg.includes('unique') || msg.includes('duplicate')) {
    userMsg = 'A record with these details already exists.';
  } else if (msg.includes('not found') || msg.includes('0 rows')) {
    userMsg = 'Record not found — it may have been deleted by another user.';
  } else if (msg.includes('permission')) {
    userMsg = 'You do not have permission to perform this action.';
  }
  
  utils.showNotification({
    title: `${action.charAt(0).toUpperCase() + action.slice(1)} failed`,
    description: userMsg,
    notificationType: 'error',
    duration: 8000
  });
  
} finally {
  await isProcessing.setValue(false);
}
```

## Common mistakes

- **Forgetting the finally block when using try/catch, leaving loading state permanently true after a failure** — undefined Fix: Always add finally { await isLoading.setValue(false); } to ensure loading state resets whether the try block succeeds or fails.
- **Using a Failure event handler on a query but the notification always shows 'undefined'** — undefined Fix: Use {{ queryName.error?.message || 'Unknown error' }} instead of {{ queryName.error.message }}. The message property can be undefined for some error types.
- **Silently catching errors with an empty catch block** — undefined Fix: Never use catch(err) {} with an empty body. Always log the error and show a notification. Empty catch blocks hide bugs and leave users confused.
- **Not handling errors in JS Queries that trigger other queries with await** — undefined Fix: When await query.trigger() fails, it throws. Without try/catch, the error propagates silently. Wrap all await trigger() calls in try/catch blocks.

## Best practices

- Every query that can fail (mutations, REST API calls, complex SQL) should have either a Failure event handler or be wrapped in try/catch.
- Always use a finally block to reset loading state variables — if you only clear loading state in the success path, failures leave the UI stuck.
- Map technical error messages (unique constraint violation, foreign key violation) to user-friendly explanations in your catch block.
- Validate inputs before triggering write queries — faster feedback and fewer unnecessary database roundtrips.
- Log errors to the console with console.error() for debugging, even when you show a user-friendly message in the notification.
- Set notification duration explicitly for error messages — use 6-8 seconds so users have time to read the error details.
- Reference queryName.error.message in Failure event handler descriptions rather than a generic 'Something went wrong' message.

## Frequently asked questions

### Does the query Failure event handler also catch JS Query errors, or only SQL/REST failures?

Query Failure event handlers catch failures from any query type — SQL queries, REST API queries, and JavaScript queries. For JS Queries, a Failure is triggered when the query throws an unhandled error or explicitly throws with throw new Error(). If you handle the error in try/catch and do not re-throw, the Failure handler does not fire.

### Can I retry a failed query automatically in Retool?

Retool does not have built-in retry functionality, but you can implement it in a JS Query using a retry loop: for (let attempt = 1; attempt <= 3; attempt++) { try { await query.trigger(); break; } catch (err) { if (attempt === 3) throw err; await new Promise(r => setTimeout(r, 1000 * attempt)); } }. This retries up to 3 times with exponential backoff.

### Where do query errors go if I do not add error handling?

Unhandled query errors are logged to the Debug Panel console and the browser DevTools console. The query's .error property is populated, and isFetching returns to false. From the user's perspective, nothing happens — the app appears frozen or unresponsive. This is why error handling is essential for production apps.

---

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