<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Ghofrane Baaziz</title>
    <description>The latest articles on Forem by Ghofrane Baaziz (@ghofrane_baaziz_aea1d4056).</description>
    <link>https://forem.com/ghofrane_baaziz_aea1d4056</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3597051%2F7f2a7127-8972-4166-b034-78780ed99179.png</url>
      <title>Forem: Ghofrane Baaziz</title>
      <link>https://forem.com/ghofrane_baaziz_aea1d4056</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/ghofrane_baaziz_aea1d4056"/>
    <language>en</language>
    <item>
      <title>How do you add block-level AI actions to a Retool custom component?</title>
      <dc:creator>Ghofrane Baaziz</dc:creator>
      <pubDate>Wed, 22 Apr 2026 14:38:51 +0000</pubDate>
      <link>https://forem.com/ghofrane_baaziz_aea1d4056/how-do-you-add-block-level-ai-actions-to-a-retool-custom-component-2ep9</link>
      <guid>https://forem.com/ghofrane_baaziz_aea1d4056/how-do-you-add-block-level-ai-actions-to-a-retool-custom-component-2ep9</guid>
      <description>&lt;p&gt;We built a custom AI-powered rich text editor as a Retool custom component for a client's internal support tool and walked through it live at Retool's Build Together session this week. The component gives support operators block-level AI actions: rewrite a paragraph, summarize a section, draft a reply from ticket context without leaving the tool.&lt;/p&gt;

&lt;p&gt;This post covers why we built it as a custom component, how the architecture works, and how to wire it up in your own Retool app.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/StackdropCO/retool-ai-editor" rel="noopener noreferrer"&gt;github.com/StackdropCO/retool-ai-editor&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Why can't you use a Retool text area with AI actions?
&lt;/h2&gt;

&lt;p&gt;Two reasons: granularity and structured output.&lt;/p&gt;

&lt;p&gt;Retool's stock text area gives you a string. AI acting on a string means acting on the whole thing: if an operator wants to rewrite one paragraph out of five, the text area has no concept of that block. You'd need to write your own selection logic, parse the string, find the boundaries, and reconstruct the document after the transformation. That logic lives outside Retool's low-code surface entirely.&lt;/p&gt;

&lt;p&gt;The second problem is downstream. Once the operator is done editing, you need to send structured data to queries, tables, and workflow nodes. Parsing a markdown or HTML blob at that stage is unnecessary work.&lt;/p&gt;

&lt;p&gt;The combination of block-level targeting and structured JSON output pointed to &lt;a href="https://editorjs.io/" rel="noopener noreferrer"&gt;editor.js&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  What does editor.js give you that a text area doesn't?
&lt;/h2&gt;

&lt;p&gt;Editor.js is a block-style rich text editor, think Notion's content model. Every paragraph, heading, and list item is its own independent JSON object. The output isn't a string, it's a structured document:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"blocks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can see this directly in Retool's inspector panel when the component is selected, the &lt;code&gt;editorData&lt;/code&gt; output updates in real time as the operator types, and you can bind it to any downstream component:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{{ AIEditor1.editorData.blocks }}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each block has its own type, content, and identity. That means you can target a specific block with an AI transformation without touching anything else in the document.&lt;/p&gt;

&lt;p&gt;Editor.js also runs as real React inside Retool's custom component environment so no sandboxing, no allow list. It installs from npm without modification and supports hooks, refs, effects, CSS modules, and TypeScript.&lt;/p&gt;




&lt;h2&gt;
  
  
  How does the AI wiring work inside a Retool custom component?
&lt;/h2&gt;

&lt;p&gt;The component exposes two events to Retool: &lt;strong&gt;&lt;code&gt;aiTransformRequest&lt;/code&gt;&lt;/strong&gt; and &lt;strong&gt;&lt;code&gt;editorChange&lt;/code&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;When an operator triggers an AI action, the component sets a group of state properties first, then fires &lt;code&gt;aiTransformRequest&lt;/code&gt;. This is a standard Retool custom component pattern: events carry no payload, so state is set before the event fires, and the connected query reads from state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Component sets state, then fires the event&lt;/span&gt;
&lt;span class="nf"&gt;setTransformBlockContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;setTransformType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;setTransformBlockId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blockId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;setTransformScope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;aiTransformRequest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;// In Retool, the query reads:&lt;/span&gt;
&lt;span class="p"&gt;{{&lt;/span&gt; &lt;span class="nx"&gt;AIEditor1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transformBlockContent&lt;/span&gt; &lt;span class="p"&gt;}}&lt;/span&gt;
&lt;span class="p"&gt;{{&lt;/span&gt; &lt;span class="nx"&gt;AIEditor1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transformType&lt;/span&gt; &lt;span class="p"&gt;}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Retool runs the connected &lt;code&gt;ai_transform&lt;/code&gt; JavaScript query, which calls your LLM endpoint, handles the response, and pushes the result back into the component:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;aiEditor1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;transformResponse&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;salt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;salt&lt;/code&gt; is &lt;code&gt;String.fromCharCode(8203)&lt;/code&gt;, a zero-width space that forces Retool to detect a value change even when the AI returns identical text twice in a row.&lt;/p&gt;

&lt;p&gt;The prompt, the model, and the context all live in the Retool query, not inside the component. That means anyone configuring the app can change how the AI behaves without touching component code.&lt;/p&gt;

&lt;p&gt;The full data flow in the query graph: &lt;code&gt;aiEditor1&lt;/code&gt; → &lt;code&gt;ai_transform&lt;/code&gt; → &lt;code&gt;anthropic_api&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  What AI actions does the component support?
&lt;/h2&gt;

&lt;p&gt;Three actions, and they work differently depending on how they're triggered.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rewrite and Summarize: block tune actions
&lt;/h3&gt;

&lt;p&gt;Every block gets an AI action menu in its settings panel (the three-dot toolbar on hover). Rewrite and Summarize live here. When triggered, the component fires &lt;code&gt;aiTransformRequest&lt;/code&gt; with &lt;code&gt;transformScope: 'block'&lt;/code&gt; and the block's text content in &lt;code&gt;transformBlockContent&lt;/code&gt;. The LLM response replaces that specific block in place via &lt;code&gt;editor.blocks.update(id, data)&lt;/code&gt; no index math, no re-inserts.&lt;/p&gt;

&lt;h3&gt;
  
  
  Draft reply: slash-menu action
&lt;/h3&gt;

&lt;p&gt;Typing &lt;code&gt;/&lt;/code&gt; opens the slash menu with a &lt;code&gt;Draft reply&lt;/code&gt; entry. Selecting it inserts a dimmed, pulsing placeholder block and fires &lt;code&gt;aiTransformRequest&lt;/code&gt; with &lt;code&gt;transformScope: 'insert'&lt;/code&gt; and &lt;code&gt;transformType: 'draft'&lt;/code&gt;. For this action, &lt;code&gt;transformBlockContent&lt;/code&gt; is empty, the draft is grounded entirely from app state you pass to the query: ticket context, previous messages, CRM data, whatever's available. The component splits the response on blank lines and replaces the placeholder with one paragraph block per chunk.&lt;/p&gt;




&lt;h2&gt;
  
  
  What does the file structure look like?
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/
  components/
    AIEditor/
      index.tsx             # React component — all Retool state/event wiring
      AITransformTune.ts    # Block Tune: per-block Rewrite and Summarize actions
      AIAction.ts           # Block Tool: slash-menu Draft reply action
      AIEditor.module.css   # Container styles
  index.tsx                 # Barrel export
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Retool-specific surface is contained in &lt;code&gt;index.tsx&lt;/code&gt;. To use this outside Retool, swap the Retool state hooks for &lt;code&gt;useState&lt;/code&gt; and component props, everything else is pure editor.js and React.&lt;/p&gt;




&lt;h2&gt;
  
  
  How do you add this component to a Retool app?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Clone and install&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/StackdropCO/retool-ai-editor ai-editor-retool
&lt;span class="nb"&gt;cd &lt;/span&gt;ai-editor-retool
npm &lt;span class="nb"&gt;install
&lt;/span&gt;npx retool-ccl login
npx retool-ccl dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dev mode syncs your local changes to Retool on every save. Drag the &lt;code&gt;AIEditor&lt;/code&gt; component onto any canvas.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Create your transform query&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Create a JavaScript query that calls your LLM endpoint (Retool AI, OpenAI, Anthropic, or any other). Switch on &lt;code&gt;{{ AIEditor1.transformScope }}&lt;/code&gt; to handle block transforms vs. draft inserts, then on &lt;code&gt;{{ AIEditor1.transformType }}&lt;/code&gt; for the specific action. Use &lt;code&gt;{{ AIEditor1.transformBlockContent }}&lt;/code&gt; as user content for block-scope transforms.&lt;/p&gt;

&lt;p&gt;The query runs in the full Retool app scope, so you can reference any query or component directly in the prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{{ someTable.selectedRow }}
{{ ticketQuery.data }}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Add the event handler&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;On the &lt;code&gt;AIEditor1&lt;/code&gt; component:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Event:&lt;/strong&gt; &lt;code&gt;aiTransformRequest&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Action:&lt;/strong&gt; Trigger your transform query&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Then:&lt;/strong&gt; Set &lt;code&gt;AIEditor1.transformResponse&lt;/code&gt; to &lt;code&gt;{{ yourTransformQuery.data }}&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The component handles the rest, routing the response to the right block, replacing the placeholder, or updating in place.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Deploy&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx retool-ccl deploy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This pushes an immutable version to your Retool instance. Anyone in the org can drag it onto a canvas like any stock component.&lt;/p&gt;




&lt;h2&gt;
  
  
  Component properties reference
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Output: component writes, your app reads
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Property&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;editorData&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;object&lt;/td&gt;
&lt;td&gt;Full editor.js JSON, updated on every change (debounced 400ms)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;transformBlockContent&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;string&lt;/td&gt;
&lt;td&gt;Text of the block being transformed (empty for &lt;code&gt;insert&lt;/code&gt; scope)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;transformType&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;string&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;summarize&lt;/code&gt;, &lt;code&gt;rewrite&lt;/code&gt;, or &lt;code&gt;draft&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;transformBlockId&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;string&lt;/td&gt;
&lt;td&gt;editor.js id of the block being transformed or replaced&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;transformScope&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;string&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;block&lt;/code&gt; (tune-menu) or &lt;code&gt;insert&lt;/code&gt; (slash-menu Draft reply)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Input: your app writes, component reads
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Property&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;transformResponse&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;string&lt;/td&gt;
&lt;td&gt;LLM response. Bind to your query result.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;readOnly&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;boolean&lt;/td&gt;
&lt;td&gt;Toggle read-only mode from your app.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  What are the limits of this component?
&lt;/h2&gt;

&lt;p&gt;It starts to feel heavy past around 50 blocks, fine for replies, drafts, and short-form content, but not the right fit for long documents or articles.&lt;/p&gt;

&lt;p&gt;No collaborative editing. This is an editor.js limitation. The library doesn't support it natively, and neither does this component.&lt;/p&gt;

&lt;p&gt;No streaming on AI responses. The editor waits for the full response before rendering. It shows a "Drafting reply..." loading state while the query runs.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's next?
&lt;/h2&gt;

&lt;p&gt;We're working on a &lt;a href="https://www.bynder.com" rel="noopener noreferrer"&gt;Bynder&lt;/a&gt; asset management integration as the next component. Same approach: encapsulate a third-party library's API into a custom component, expose enough surface for end users to configure without touching code.&lt;/p&gt;

&lt;p&gt;If you have questions on the build or want to adapt it for a different use case, drop them in the comments.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/StackdropCO/retool-ai-editor" rel="noopener noreferrer"&gt;github.com/StackdropCO/retool-ai-editor&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Built by John Miniadis at &lt;a href="https://www.stackdrop.co" rel="noopener noreferrer"&gt;Stackdrop&lt;/a&gt;, a Retool-certified agency building governed internal tools for mid-market and enterprise clients across EMEA.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can I use this editor.js component outside of Retool?&lt;/strong&gt;&lt;br&gt;
Yes. The Retool-specific wiring is isolated to one file (&lt;code&gt;index.tsx&lt;/code&gt;). Replace the Retool state hooks with &lt;code&gt;useState&lt;/code&gt; and component props, and you have a standalone React component with no Retool dependency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this work with any LLM, or only Anthropic?&lt;/strong&gt;&lt;br&gt;
Any LLM endpoint works. The component fires a Retool event, and your query handles the API call. You can use Retool AI, OpenAI, Anthropic, or any custom endpoint. The response shape handling in the query supports multiple formats (&lt;code&gt;data?.content?.[0]?.text&lt;/code&gt;, &lt;code&gt;data?.completion&lt;/code&gt;, &lt;code&gt;data?.answer&lt;/code&gt;, &lt;code&gt;data?.text&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How does the component pass block content to the Retool query?&lt;/strong&gt;&lt;br&gt;
Retool custom component events carry no payload, so the component sets state properties before firing the event. Your query reads &lt;code&gt;{{ AIEditor1.transformBlockContent }}&lt;/code&gt; for the block text, &lt;code&gt;{{ AIEditor1.transformType }}&lt;/code&gt; for the action, and &lt;code&gt;{{ AIEditor1.transformScope }}&lt;/code&gt; to distinguish between block transforms and slash-menu inserts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens if the AI returns the same text twice in a row?&lt;/strong&gt;&lt;br&gt;
The component appends a zero-width space (&lt;code&gt;String.fromCharCode(8203)&lt;/code&gt;) to every response before writing it back. This forces Retool to detect a value change and trigger the update even when the text is identical.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When should I use a custom component instead of a stock Retool text area?&lt;/strong&gt;&lt;br&gt;
When you need structured output (JSON per block rather than a string), block-level event handling, or UI elements that stock inputs don't support, custom menus, inline toolbars, placeholder animations, and slash commands. If a string output and simple AI actions on the whole field are sufficient, the stock text area is the simpler choice.&lt;/p&gt;

</description>
      <category>retool</category>
      <category>editorjs</category>
      <category>internaltools</category>
      <category>react</category>
    </item>
    <item>
      <title>PostgreSQL INT4RANGE: Enforce non-overlapping zones in SQL</title>
      <dc:creator>Ghofrane Baaziz</dc:creator>
      <pubDate>Mon, 20 Apr 2026 22:11:32 +0000</pubDate>
      <link>https://forem.com/ghofrane_baaziz_aea1d4056/postgresql-int4range-enforce-non-overlapping-zones-in-sql-epe</link>
      <guid>https://forem.com/ghofrane_baaziz_aea1d4056/postgresql-int4range-enforce-non-overlapping-zones-in-sql-epe</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally written by &lt;strong&gt;&lt;a href="https://github.com/omartarek206" rel="noopener noreferrer"&gt;Omar Tarek&lt;/a&gt;&lt;/strong&gt;, Software Developer at Stackdrop. Posted here with his permission.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;When you need to assign geographic zones to records in PostgreSQL without overlap, the INT4RANGE type handles range storage, containment queries, and conflict enforcement natively without you writing a single manual comparison.&lt;/p&gt;




&lt;h2&gt;
  
  
  The problem: why range assignments fail silently with start/end fields
&lt;/h2&gt;

&lt;p&gt;Here's a schema most people reach for first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;postal_code_assignments&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;assignment_id&lt;/span&gt; &lt;span class="nb"&gt;SERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;agent_id&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;start_code&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;end_code&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Single code lookups are fine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;agent_id&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;postal_code_assignments&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="mi"&gt;19406&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="n"&gt;start_code&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;end_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;start_code&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Overlap detection is not. To check whether an incoming range &lt;code&gt;(A, B)&lt;/code&gt; conflicts with an existing &lt;code&gt;(C, D)&lt;/code&gt;, you have to manually cover: partial left overlap, partial right overlap, full containment in either direction, and single-value edge cases. That logic has to live somewhere in the application, it has to be replicated everywhere ranges are written, and the database will not catch it if you miss a path.&lt;/p&gt;

&lt;p&gt;We built an admin tool for a sales operations team where agents are assigned postal code zones across a country. A silent overlap means one postal code shows up in two agents' zones, duplicated contacts, broken sales attribution, and no visibility into which assignment is wrong. We needed the database to enforce this, not the application.&lt;/p&gt;




&lt;h2&gt;
  
  
  INT4RANGE: what it is and how it works
&lt;/h2&gt;

&lt;p&gt;INT4RANGE is a native PostgreSQL type that stores an integer range as a single value. The notation follows standard interval math:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;[19400,19500]&lt;/code&gt;: 19400 through 19500 inclusive&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;[19306,19307)&lt;/code&gt;: exactly 19306 (single value in canonical half-open form)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;(19300,19400)&lt;/code&gt;: all integers strictly between the bounds&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The type ships with operators you actually need: &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; (overlap), &lt;code&gt;&amp;lt;@&lt;/code&gt; (containment), &lt;code&gt;-|-&lt;/code&gt; (adjacency). These work with GiST indexes, so the database can run them efficiently at query and write time.&lt;/p&gt;

&lt;p&gt;The schema:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;postal_code_assignments&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;assignment_id&lt;/span&gt; &lt;span class="nb"&gt;SERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;agent_id&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;postal_code_range&lt;/span&gt; &lt;span class="n"&gt;INT4RANGE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;deleted&lt;/span&gt; &lt;span class="nb"&gt;BOOLEAN&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;FOREIGN&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agent_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;sales_agents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agent_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;EXCLUDE&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;GIST&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;postal_code_range&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;deleted&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;TRUE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;EXCLUDE USING GIST&lt;/code&gt; line is the key part. It tells PostgreSQL to index the range column with GiST and reject any insert or update where the new range overlaps an existing one. The partial predicate &lt;code&gt;WHERE (deleted IS NOT TRUE)&lt;/code&gt; excludes soft-deleted rows, so you can keep historical assignments without triggering false conflicts.&lt;/p&gt;




&lt;h2&gt;
  
  
  Inserts, lookups, and display
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Single code:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;postal_code_assignments&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agent_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;postal_code_range&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'[19306,19307)'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Range:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;postal_code_assignments&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agent_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;postal_code_range&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'[19400,19500]'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any overlapping insert is rejected immediately by the constraint. No pre-check query needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ownership lookup:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;agent_id&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;postal_code_assignments&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;int4range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;19406&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;19407&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;@&lt;/span&gt; &lt;span class="n"&gt;postal_code_range&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Readable display&lt;/strong&gt; (PostgreSQL normalizes to half-open form, so subtract 1 from upper for inclusive end):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;assignment_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;agent_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;postal_code_range&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;start_postal_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;postal_code_range&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;end_postal_code&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;postal_code_assignments&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  How does EXCLUDE USING GIST enforce non-overlapping zones?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;EXCLUDE USING GIST&lt;/code&gt; is a table constraint that uses a GiST index to enforce that no two rows satisfy a given operator condition. In this case, the overlap operator &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; applied between any two rows.&lt;/p&gt;

&lt;p&gt;GiST (Generalized Search Tree) is PostgreSQL's extensible indexing framework. Range types are designed to work with it, so the index can efficiently check whether an incoming value overlaps anything already in the table before the write is committed.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;WHERE (deleted IS NOT TRUE)&lt;/code&gt; partial predicate means soft-deleted rows are outside the constraint scope. Assignments that have been superseded stay in the table for audit purposes without blocking new assignments that cover the same zones.&lt;/p&gt;

&lt;p&gt;Overlap validation moves from application code into the database itself. No pre-insert check query in your API, no validation function in your ORM, no race-condition-prone read-then-write pattern. The constraint fires on every write path.&lt;/p&gt;




&lt;h2&gt;
  
  
  Surfacing conflicts before writing updates
&lt;/h2&gt;

&lt;p&gt;The GIST constraint blocks invalid writes but raises an error without context. In an admin UI, the admin needs to see both the incoming assignment and the existing conflicting record. A CTE handles this cleanly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;updates&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;VALUES&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'[19400,19450]'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;int4range&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'[19500,19600]'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;int4range&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sales_person_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new_range&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="n"&gt;conflicts&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;update_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;existing_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;postal_code_range&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;postal_code_assignments&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
    &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;updates&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;postal_code_range&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;new_range&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="n"&gt;update_action&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;postal_code_assignments&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
    &lt;span class="k"&gt;SET&lt;/span&gt;
      &lt;span class="n"&gt;postal_code_range&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;new_range&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;agent_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sales_person_id&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;updates&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
    &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;conflicts&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;update_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="s1"&gt;'conflict'&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;update_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;existing_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;postal_code_range&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;conflicts&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="s1"&gt;'update'&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;INT4RANGE&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;update_action&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three stages: &lt;code&gt;updates&lt;/code&gt; holds the incoming batch, &lt;code&gt;conflicts&lt;/code&gt; checks each against existing rows using &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt;, &lt;code&gt;update_action&lt;/code&gt; writes only the clean ones. The result set comes back tagged &lt;code&gt;'conflict'&lt;/code&gt; rows carry the overlapping record details, &lt;code&gt;'update'&lt;/code&gt; rows confirm what was saved. The application routes on the &lt;code&gt;result&lt;/code&gt; column.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;In our case, the updates CTE is populated via a Retool template literal, injecting a dynamic array at runtime. In a standard backend, replace that with a parameterized VALUES list from your application layer.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Tradeoffs worth knowing
&lt;/h2&gt;

&lt;p&gt;INT4RANGE is PostgreSQL-specific. If you ever need to run this on MySQL or SQLite, you are back to the start/end approach. For Postgres-native stacks, the tradeoffs are almost entirely in its favour:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Overlap detection is a single operator, not a multi-condition WHERE&lt;/li&gt;
&lt;li&gt;The constraint lives in the schema, not scattered across application code&lt;/li&gt;
&lt;li&gt;GiST indexing performs well on containment and overlap queries at scale&lt;/li&gt;
&lt;li&gt;The only real cost is a small write overhead from the GiST index; negligible for admin tool volumes, worth benchmarking at high insert rates&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;If you are on PostgreSQL and need non-overlapping integer ranges, INT4RANGE with &lt;code&gt;EXCLUDE USING GIST&lt;/code&gt; is the right tool. It enforces the invariant at the database level, keeps overlap logic out of your application, and handles containment and conflict queries with native operators.&lt;/p&gt;

&lt;p&gt;Happy to answer questions on the CTE pattern or the GiST constraint setup.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>sql</category>
      <category>database</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Why Retool's PDF Component Doesn't Render Your Signed S3 URL (And How to Fix It)</title>
      <dc:creator>Ghofrane Baaziz</dc:creator>
      <pubDate>Mon, 20 Apr 2026 12:39:58 +0000</pubDate>
      <link>https://forem.com/ghofrane_baaziz_aea1d4056/why-retools-pdf-component-doesnt-render-your-signed-s3-url-and-how-to-fix-it-dfk</link>
      <guid>https://forem.com/ghofrane_baaziz_aea1d4056/why-retools-pdf-component-doesnt-render-your-signed-s3-url-and-how-to-fix-it-dfk</guid>
      <description>&lt;p&gt;Originally written by &lt;a href="http://github.com/aroo530/" rel="noopener noreferrer"&gt;Arsany Milad&lt;/a&gt; - Software Developer @ Stackdrop, posted here with his permission&lt;/p&gt;

&lt;p&gt;Retool's PDF component uses &lt;code&gt;fetch()&lt;/code&gt; to load files. Browsers enforce CORS on fetch requests. If your S3 bucket isn't configured to allow requests from Retool's origin, the request gets blocked silently and the component renders nothing. Adding Retool's domain to the bucket's CORS configuration fixes it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does a signed S3 URL load correctly in a browser tab but fail silently inside Retool's PDF component?
&lt;/h2&gt;

&lt;p&gt;On a random Friday I found myself working on an invoicing feature: add expense info, upload files, save, done. But when I tried to open the uploaded PDF inside the app it just showed "PDF couldn't be loaded."&lt;/p&gt;

&lt;p&gt;![PDF could not be loaded error in Retool]&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9ck4khb40gxd9m9yuisv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9ck4khb40gxd9m9yuisv.png" alt=" " width="800" height="559"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Retool console wasn't helpful, so I checked the browser console and found this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Access to fetch at 'https://&amp;lt;s3-endpoint&amp;gt;/&amp;lt;path&amp;gt;/document.pdf?&amp;lt;signed-url-params&amp;gt;'
from origin 'https://app.retool.com' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The signed URL was valid — pasting it directly into the browser tab opened the PDF without issue. The problem was specific to the PDF component.&lt;/p&gt;

&lt;p&gt;Here's why. When you paste a URL into the address bar, the browser performs a top-level navigation request. CORS rules don't apply to navigation. When Retool's PDF component loads a file, it calls &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch" rel="noopener noreferrer"&gt;fetch()&lt;/a&gt; internally. Fetch requests are cross-origin requests, and the browser enforces CORS on them. If the S3 bucket's CORS configuration doesn't include Retool's domain in &lt;code&gt;AllowedOrigins&lt;/code&gt;, the browser blocks the response before the component receives anything.&lt;/p&gt;

&lt;p&gt;Both the URL and the signature are valid. The bucket just hasn't been told that Retool's domain is allowed to make cross-origin requests to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you configure S3 CORS to allow Retool's PDF component to load signed URLs?
&lt;/h2&gt;

&lt;p&gt;This is a CORS configuration change on the S3 bucket. No IAM changes, no bucket policy edits, no changes to how you generate signed URLs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Open the bucket in AWS&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Log into the AWS Console&lt;/li&gt;
&lt;li&gt;Navigate to S3&lt;/li&gt;
&lt;li&gt;Select the bucket your Retool app is reading from&lt;/li&gt;
&lt;li&gt;Go to Permissions&lt;/li&gt;
&lt;li&gt;Scroll to CORS configuration&lt;/li&gt;
&lt;li&gt;Click Edit&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Step 2: Add the CORS configuration&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"AllowedHeaders"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"AllowedMethods"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"GET"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"HEAD"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"AllowedOrigins"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"https://app.retool.com"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ExposeHeaders"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"ETag"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Content-Type"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Content-Disposition"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"MaxAgeSeconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your Retool instance runs on a custom subdomain (e.g. &lt;code&gt;https://yourcompany.retool.com&lt;/code&gt;), replace &lt;code&gt;https://app.retool.com&lt;/code&gt; with your actual domain. Use only the specific domains you control — wildcards like &lt;code&gt;*.retool.com&lt;/code&gt; cover every Retool-hosted app across all customers, not just yours.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What each field does:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AllowedOrigins&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Tells S3 which domains are permitted to make cross-origin requests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AllowedMethods&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Restricts which HTTP methods those origins can use&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AllowedHeaders&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Permits the headers Retool includes in its fetch requests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ExposeHeaders&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Makes file metadata — content type, disposition, ETag — readable by the browser&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MaxAgeSeconds&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Controls how long the browser caches the preflight response&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note on PUT:&lt;/strong&gt; The template above uses &lt;code&gt;GET&lt;/code&gt; and &lt;code&gt;HEAD&lt;/code&gt; only, which is enough for rendering PDFs. If your app also uploads files directly to S3 from the browser via presigned upload URLs — as in my case with invoice uploads — add &lt;code&gt;"PUT"&lt;/code&gt; to &lt;code&gt;AllowedMethods&lt;/code&gt;. If uploads go through your backend, you don't need it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Step 3: Save and verify&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Save the configuration. Hard-refresh Retool with &lt;code&gt;Ctrl+Shift+R&lt;/code&gt;. Open DevTools → Network tab, reload the PDF component. The S3 response should now include:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Access-Control-Allow-Origin: https://app.retool.com
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;![PDF rendering correctly after CORS fix]&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzqqqkoh41aq9fmggtxt1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzqqqkoh41aq9fmggtxt1.png" alt=" " width="800" height="559"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  How should S3 CORS be configured when Retool has separate production and staging environments?
&lt;/h2&gt;

&lt;p&gt;List each domain explicitly in &lt;code&gt;AllowedOrigins&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"AllowedOrigins"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="s2"&gt;"https://yourapp.retool.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="s2"&gt;"https://yourapp-staging.retool.com"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep this list as narrow as possible. A wildcard (&lt;code&gt;"*"&lt;/code&gt;) on a private bucket allows cross-origin requests from any domain. Explicit origins are always the right call on a bucket serving private documents.&lt;/p&gt;

&lt;h2&gt;
  
  
  When does an S3 CORS configuration for Retool need PUT or POST in AllowedMethods?
&lt;/h2&gt;

&lt;p&gt;For rendering PDFs and images, &lt;code&gt;GET&lt;/code&gt; and &lt;code&gt;HEAD&lt;/code&gt; are sufficient. You only need &lt;code&gt;PUT&lt;/code&gt; if the browser is uploading files directly to S3 via presigned upload URLs initiated from the frontend. If uploads go through your backend or a Retool resource connection, the browser never makes the upload request, so &lt;code&gt;PUT&lt;/code&gt; isn't needed.&lt;/p&gt;

&lt;p&gt;Only add methods you actively use. Each one you include extends the surface area of what cross-origin requests can do on that bucket.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are the most common mistakes that cause S3 CORS issues to persist in Retool after applying a fix?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Treating the symptom as a signing issue.&lt;/strong&gt; The signed URL works in the browser, so the signature is fine. CORS is a separate layer. The URL can be perfectly valid and still get blocked at the fetch level.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Using &lt;code&gt;"*"&lt;/code&gt; as the allowed origin.&lt;/strong&gt; This removes the origin restriction entirely. On a private bucket serving sensitive documents, explicit domains are the right call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Using &lt;code&gt;*.retool.com&lt;/code&gt; as the allowed origin.&lt;/strong&gt; This wildcard covers every Retool-hosted app across all customers, not just yours. Always use your specific Retool domain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Skipping the hard refresh.&lt;/strong&gt; Browsers cache preflight responses for the duration set in &lt;code&gt;MaxAgeSeconds&lt;/code&gt;. If you test immediately after saving without a hard refresh, the browser may still be acting on the cached response from before the fix. &lt;code&gt;Ctrl+Shift+R&lt;/code&gt; clears this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Editing the wrong bucket.&lt;/strong&gt; If production and staging use different buckets, confirm which one your Retool app is reading from before making the change.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is the workaround for loading private S3 files in Retool when the bucket CORS configuration cannot be modified?
&lt;/h2&gt;

&lt;p&gt;Two options.&lt;/p&gt;

&lt;p&gt;The first is to use Retool's built-in S3 resource to read the file server-side. Because the request originates from Retool's backend rather than the browser, CORS doesn't apply. The tradeoff is memory management — PDFs are large and you'll need to handle query cleanup after use to avoid memory issues.&lt;/p&gt;

&lt;p&gt;The second is to proxy the file through your own backend: fetch the object server-side, encode it as base64, and return it to Retool as a data URI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;data:application/pdf;base64,...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same principle — server-to-server requests aren't subject to browser CORS enforcement. The memory and latency cost scales with document size, which becomes significant with large files or high request volume. Modifying the bucket CORS configuration is the better long-term solution where access permits it.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why does a signed S3 URL open correctly in a browser tab but fail to load inside Retool's PDF component?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When you open a signed URL directly in a browser tab, the browser performs a top-level navigation request. CORS rules don't apply to navigation. When Retool's PDF component loads the same URL, it uses &lt;code&gt;fetch()&lt;/code&gt; internally. Fetch requests are cross-origin requests and the browser enforces CORS on them. If the S3 bucket's CORS configuration doesn't include Retool's domain in &lt;code&gt;AllowedOrigins&lt;/code&gt;, the browser blocks the response before the component receives anything. The PDF component renders nothing and gives no visible error in the UI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I fix a CORS error blocking a signed S3 URL from loading in the Retool PDF component?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Add your specific Retool domain to the CORS configuration on the S3 bucket serving the files. In the AWS Console, go to S3 → your bucket → Permissions → CORS configuration → Edit, and add &lt;code&gt;https://app.retool.com&lt;/code&gt; (or your custom subdomain) to &lt;code&gt;AllowedOrigins&lt;/code&gt;. Save the config and hard-refresh Retool with &lt;code&gt;Ctrl+Shift+R&lt;/code&gt;. This is a bucket-level change — no IAM or bucket policy edits are required.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I updated the S3 CORS configuration but Retool's PDF component is still blocked. What should I check?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The most common cause is a cached preflight response. Browsers cache CORS preflight results for the duration set in &lt;code&gt;MaxAgeSeconds&lt;/code&gt;. If you test without a hard refresh (&lt;code&gt;Ctrl+Shift+R&lt;/code&gt;), the browser may still be acting on the cached response from before the fix. If a hard refresh doesn't resolve it, open DevTools → Network and confirm &lt;code&gt;Access-Control-Allow-Origin&lt;/code&gt; is now present in the S3 response. If it's still missing, verify you edited the correct bucket — production and staging buckets are separate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does the S3 CORS fix for Retool's PDF component also apply to image components and other file types?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Any Retool component that loads files using &lt;code&gt;fetch()&lt;/code&gt; can be blocked by missing CORS headers. The PDF component is the most common case because PDFs are typically served from private buckets via signed URLs. If you encounter the same symptom with another component type — the URL works in a browser but the component renders nothing — the fix is the same: add Retool's domain to &lt;code&gt;AllowedOrigins&lt;/code&gt; on the S3 bucket serving those files.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is there a workaround for loading private S3 files in Retool when the bucket CORS configuration cannot be modified?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. You can use Retool's built-in S3 resource to fetch the file server-side, or proxy it through your own backend and return it as a base64 data URI (&lt;code&gt;data:application/pdf;base64,...&lt;/code&gt;). Both approaches bypass browser CORS enforcement because the request originates from a server rather than the browser. The memory and latency cost scales with document size — modifying the bucket CORS configuration is the better long-term solution where access permits it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Arsany Milad is a developer at &lt;a href="https://stackdrop.co/?utm_medium=social&amp;amp;utm_source=devto" rel="noopener noreferrer"&gt;Stackdrop&lt;/a&gt;, a Retool-certified agency building governed internal tools for mid-market and enterprise clients across EMEA.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>aws</category>
      <category>lowcode</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I built a custom range slider for Retool with a histogram built in</title>
      <dc:creator>Ghofrane Baaziz</dc:creator>
      <pubDate>Mon, 24 Nov 2025 13:24:21 +0000</pubDate>
      <link>https://forem.com/ghofrane_baaziz_aea1d4056/i-built-a-custom-range-slider-for-retool-with-a-histogram-built-in-5491</link>
      <guid>https://forem.com/ghofrane_baaziz_aea1d4056/i-built-a-custom-range-slider-for-retool-with-a-histogram-built-in-5491</guid>
      <description>&lt;h2&gt;
  
  
  I built a custom range slider for Retool with a histogram built in
&lt;/h2&gt;

&lt;p&gt;Range sliders in Retool are great until you need to understand your data. They let you pick min and max values, but they don’t tell you anything about the distribution itself.&lt;/p&gt;

&lt;p&gt;I kept running into this when building internal tools, so I built a custom range slider that includes a histogram, handles uneven distributions and exposes clean values. It’s written in TypeScript and works as a native Retool component.&lt;/p&gt;

&lt;p&gt;🔗 &lt;strong&gt;GitHub repo:&lt;/strong&gt; &lt;a href="https://github.com/StackdropCO/custom-range-slider-retool-component" rel="noopener noreferrer"&gt;https://github.com/StackdropCO/custom-range-slider-retool-component&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;🔗 &lt;strong&gt;More Awesome Retool components here:&lt;/strong&gt; &lt;a href="https://github.com/StackdropCO/awesome-retool-components" rel="noopener noreferrer"&gt;https://github.com/StackdropCO/awesome-retool-components&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here’s why I built it and how to use it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I built this
&lt;/h2&gt;

&lt;p&gt;A real example that pushed me to do this:&lt;/p&gt;

&lt;p&gt;I needed a filter for “years of experience” inside a Retool app. Most candidates had between 0 and 20 years, but a few outliers had 45 years. With the standard slider, you get min and max, but no clue whether the middle ranges are empty or dense.&lt;/p&gt;

&lt;p&gt;It forces you to guess, or worse, manually inspect the dataset.&lt;/p&gt;

&lt;p&gt;To show what I mean:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retool’s default slider (no visibility):&lt;/strong&gt;  &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fo3gg22k2ksgnkcyq5xoi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fo3gg22k2ksgnkcyq5xoi.png" alt="Retool's default range slider without distribution visibility" width="688" height="152"&gt;&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Custom Range Slider with distribution visible:&lt;/strong&gt;  &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi0jnvm8ckrfh1c64o5n1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi0jnvm8ckrfh1c64o5n1.png" alt="Custom range slider component with histogram showing data distribution overlay" width="634" height="294"&gt;&lt;/a&gt;&lt;br&gt;
The histogram makes it obvious where your data actually sits. In my case, the “20 to 45 years” range was basically empty. With a logarithmic scale, even large skewed values become readable.&lt;/p&gt;

&lt;p&gt;This small change makes filtering more honest and more useful.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I focused on while building it
&lt;/h2&gt;

&lt;p&gt;Here are the engineering decisions that mattered:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Histogram built in&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Shows distribution directly on the slider.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Multiple scales&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Linear, logarithmic and square root for different distribution shapes.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Clean values&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Access the selected range through &lt;code&gt;selectedRange.start&lt;/code&gt; and &lt;code&gt;selectedRange.end&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Flexible inputs&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Arrays, number lists, query outputs — anything numeric.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Click-to-select&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Click any histogram bar to jump to that range.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Custom colors&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Fully themeable inside Retool.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Negative values supported&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Useful for datasets that include offsets or deltas.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Installation
&lt;/h2&gt;

&lt;p&gt;Clone the repo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/StackdropCO/custom-range-slider-retool-component.git

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Install dependencies
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;custom-range-slider
npm &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Log in to Retool and initialize
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx retool-ccl login
npx retool-ccl init
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Start development mode
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx retool-ccl dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Deploy the component
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx retool-ccl deploy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Using it in Retool
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Drag&lt;/strong&gt; the Range Slider component onto the canvas.&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Define start and end values:&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
&lt;code&gt;start: 0&lt;/code&gt;&lt;br&gt;&lt;br&gt;
&lt;code&gt;end: 50&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Custom formatting:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1 year&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; years`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Bind distribution data:&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
&lt;code&gt;{{ query2.dataArray }}&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Access the selected range:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;customRangeSlider1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;selectedRange&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Switch scales if your data is skewed:&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;Linear (default)&lt;/li&gt;
&lt;li&gt;Logarithmic&lt;/li&gt;
&lt;li&gt;Square root&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;/ul&gt;




&lt;h2&gt;
  
  
  Quick checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Use a &lt;strong&gt;published version&lt;/strong&gt; for production&lt;/li&gt;
&lt;li&gt;Test locally with &lt;code&gt;npx retool-ccl dev&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Deploy and &lt;strong&gt;pin the version&lt;/strong&gt; in your Retool app&lt;/li&gt;
&lt;li&gt;For multi-instance setups, use &lt;code&gt;npx retool-ccl sync&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;A few improvements I'm exploring:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Auto-generated histograms from simple arrays&lt;/li&gt;
&lt;li&gt;More control over spacing and styling&lt;/li&gt;
&lt;li&gt;Optional statistics like mean and median&lt;/li&gt;
&lt;li&gt;Additional scale types&lt;/li&gt;
&lt;li&gt;General cleanup and refinements&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Get involved
&lt;/h2&gt;

&lt;p&gt;I built this to make filtering inside Retool more &lt;strong&gt;accurate&lt;/strong&gt;, especially with uneven distributions. It's fully &lt;strong&gt;open source&lt;/strong&gt;, and I'd love feedback, ideas or pull requests.&lt;/p&gt;

&lt;p&gt;If you try it, let me know how it works for you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Happy building!&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;p&gt;Interested in learning more about this component? Check out the &lt;a href="https://stackdrop.com/components/range-slider?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=component_library" rel="noopener noreferrer"&gt;full component library and documentation&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>typescript</category>
      <category>opensource</category>
      <category>lowcode</category>
    </item>
  </channel>
</rss>
