"Can I just run a query somewhere?"
We heard this a lot after launching Forward. The CLI workflow is great for building data products — but sometimes you're not building anything. You're investigating. Prototyping. Checking if your assumptions about the data are actually true.
Classic had this. You could open a SQL editor and just... query. Forward didn't.
Playgrounds brings it back. But we didn't just port the old feature — we rebuilt it with a different architecture. This post explains the decisions behind it.
The Architecture
Playgrounds has a multi-node model. Each playground contains multiple nodes, and each node contains a SQL query. Nodes can reference each other like CTEs, forming a pipeline.
┌─────────────────────────────────────────────────────────────────────┐
│ Playgrounds │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────┐ ┌─────────────────┐ ┌────────────────────┐ │
│ │ Redis │◀────│ Python API │◀────│ React Frontend │ │
│ │ │ │ /v0/playgrounds│ │ │ │
│ └─────────┘ │ /v0/sql │ │ ┌──────────────┐ │ │
│ └─────────────────┘ │ │ CodeMirror │ │ │
│ │ │ SQL Editor │ │ │
│ │ └──────────────┘ │ │
│ │ │ │
│ │ ┌──────────────┐ │ │
│ │ │ Split Screen │ │ │
│ │ │ Panel System │ │ │
│ │ └──────────────┘ │ │
│ └────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
Our app backend is in Python. The Playground model stores properties like user_id, workspace_id, name, pipeline, shared_with. The pipeline contains an ordered list of nodes, each with its own SQL.
The frontend is React with Next.js. A resizable panel group holds the main editor and results, with a split-screen panel system for inspecting datasource schemas while you write.
Two playground types exist: "playground" for SQL exploration, and "exploration" for AI-driven chat sessions. Same underlying model, different UI.
Why Redis? Playgrounds are ephemeral by nature. They don't need the transactional guarantees of PostgreSQL. They don't need complex querying. They need fast reads, fast writes, and simple key-value semantics. Redis with atomic transactions gives us exactly that — and it's already in our stack.
The RESTful API follows a standard pattern: GET/POST /v0/playgrounds for listing and creating, GET/PUT/DELETE /v0/playgrounds/{id} for individual operations. The API layer handles access control, validates workspace membership, and triggers notifications when playgrounds are shared.
Five Design Decisions That Make It Work
1. The @workspace Sharing Model
The problem: Playgrounds need to be private by default — you don't want everyone seeing your half-baked queries. But you also need to share them with your team when you're ready.
We could have built a full permissions model: share with specific users, roles, groups. But that's complex. It requires a permissions lookup on every access. It requires UI for managing permissions. It requires explaining the model to users. And we wanted to ship fast.
The solution: A special marker in the shared_with array.
# playground.py
SHARING_SCOPE_WORKSPACE = "@workspace"
class Playground:
# Properties: user_id, workspace_id, name, pipeline, shared_with, ...
def is_shared_with_workspace(self) -> bool:
"""Check if shared with all workspace members."""
return self.shared_with is not None and SHARING_SCOPE_WORKSPACE in self.shared_with
def has_access(self, user_id: str, workspace_id: str) -> bool:
if self.workspace_id != workspace_id:
return False
if self.is_owner(user_id):
return True
if self.is_shared_with_user(user_id):
return True
return self.is_shared_with_workspace()
The shared_with field is a simple array of strings. Most values are user IDs — specific people you've shared with. But @workspace is a special marker that means "everyone in this workspace has access."
The @ prefix convention is intentional. User IDs are ULIDs, so they never start with @. This makes parsing unambiguous: if it starts with @, it's a scope marker. If it doesn't, it's a user ID. We can add more scopes later (@team-engineering?) without changing the data model.
This gave us a binary choice (private or workspace-wide) without building a full permissions system. The UI is a simple toggle. The API is simple. The mental model is simple. Room to extend later if needed.
The edge case: Non-owners updating shared playgrounds. If you can see a playground but don't own it, should you be able to edit it? We decided yes — you can edit the SQL, add nodes, run queries. But you can't change the shared_with field. Only the owner controls visibility. The API detects when a non-owner tries to modify shared_with and silently ignores that field while applying other changes.
2. The Split-Screen Panel System
The problem: When writing SQL, you often need to check a table schema. What columns does this datasource have? What's the type of that field? Is it DateTime or DateTime64?
Switching tabs breaks your flow. Pop-ups are annoying. Opening DevTools to check the API response works, but it's clunky. We wanted something that feels native to the editor experience.
The solution: A React context-based panel system. Click a table name in your SQL, and a side panel opens with the schema.
We built a React context that any component can call: openSplitScreen('dataSource', { name: 'my_table' }). The panel renders on the right. Escape closes it.
Seven panel types cover different resources you might inspect:
- DataSourcePanel: Schema and sample data from a datasource
- PipePanel: Pipeline structure and node details
- NodePanel: Another node from the same playground
- ServiceDataSourcePanel: System tables like
tinybird.datasources_ops_log - ResourcePanel: Generic resource inspection
- PlaygroundExplain: Query execution plan with
EXPLAIN
The CodeMirror editor detects clicks on syntax-highlighted table names. Each table type has a CSS class (cm-datasource, cm-pipe, cm-node), and we listen for clicks on those elements:
// Each datasource type gets a CSS class for syntax highlighting
// Click handler detects these and opens the appropriate panel
const handleClick = (editor, event: MouseEvent) => {
const element = event.target
if (!(element instanceof HTMLElement)) return
// Check if clicked element is a table name (datasource, pipe, or node)
if (element.className.startsWith('cm-datasource')) {
onTableClick?.(element.innerText)
} else if (element.classList.contains('cm-pipe')) {
onTableClick?.(element.innerText)
}
}
Escape key closes the panel. The resizable handle lets you adjust the split. Clicking a different table replaces the current panel. It feels like the schema is always right there — because it is.
This pattern emerged from refactoring. We originally had split-screen logic scattered across components. Extracting it into a shared provider made the code cleaner and the behavior consistent across Playgrounds, Explorations, and Time Series.
3. Template Parameters Detection
The problem: Tinybird pipes support template parameters with {{param}} syntax. It's how you build parameterized API endpoints. Playgrounds should support the same syntax so you can prototype parameterized queries before deploying them as pipes.
We needed to detect parameters in the SQL, provide UI inputs to set values, and pass them to the /v0/sql endpoint.
The solution: Parse the SQL on the frontend, render inputs for each detected parameter, and include values in the API request.
Two ways to pass parameters:
template_parameters: A JSON object with predefined defaults from the parameter definitions- Individual query params: User-provided values that override defaults
The backend merges them and evaluates the template before executing the SQL.
The detection happens on the frontend when rendering the node. A regex finds {{param_name}} patterns in the SQL. For each unique parameter, we render a text input. Change the value, re-run the query. No need to edit the SQL itself.
The % prefix at the start of SQL enables Jinja templating mode, unlocking {% if %} conditionals and {% for %} loops. Without the prefix, only simple {{param}} substitution works. Most playground queries don't need full Jinja, so we default to the simpler mode.
4. A Custom CodeMirror Language for Tinybird SQL
The problem: Tinybird's query language is a hybrid: ClickHouse® SQL + Tornado-style Python templating + dynamic API parameters. Standard SQL editor modes don't understand this.
-- Layer 1: ClickHouse® SQL
SELECT city, count() as visits
FROM analytics_events
-- Layer 2: Tornado templating (control flow)
{\% if defined(start_date) %}
WHERE timestamp >= {{ DateTime(start_date, '2024-01-01') }}
{\% end %}
-- Layer 3: Dynamic parameters
{{ String(filter_by, 'all', required=True) }}
The solution: We built a custom CodeMirror language extension that understands all three layers.
The implementation splits into focused modules:
tinybird-sql.ts— Core SQL dialect, completions, templating decorationstinybird-control.ts— Tornado control keywords (if,for,set,end)tinybird-functions.ts— 700+ ClickHouse® function signatures with docstinybird-setup.tsx— Editor setup and keybindingsQueryEditor.tsx— React component that assembles everything
The key innovation is context-aware completion. The editor detects which templating block you're in and provides different completions:
- Inside
{{ }}→ parameter types:String(),Int(),DateTime(),column() - Inside
{% %}→ control keywords:if,for,set,end,defined() - Outside templating → SQL completions: keywords, functions, table/column names
This works by scanning backwards from the cursor to find the most recent unclosed delimiter ({{, {%, or {#). If no unclosed block is found, we're in plain SQL.
The editor also fetches schema information dynamically. When you type FROM events, it calls DESCRIBE to get column info. Type events. and autocomplete shows only that table's columns with their types.
Each of the ClickHouse® functions includes inline documentation with snippets. Selecting argMax expands to argMax(arg, val) with tab stops for each argument.
5. AI-Assisted SQL with Diff Preview
The problem: AI can generate SQL. But blindly accepting AI output is dangerous. What if it rewrites your carefully tuned WHERE clause? What if it changes the aggregation logic? You need to review before applying.
The solution: CMD+K opens a prompt overlay. Describe what you want in natural language. The AI generates SQL. But instead of replacing your code immediately, we show a diff view. You see exactly what changed before deciding.
// PlaygroundSqlEditor.tsx
const { submit: submitSql, isLoading: isLoadingSql } = usePlaygroundSql(
playground,
nodeName || '',
{
onFinish: newSql => {
setPromptVisible(false)
if (!!sql && newSql !== sql) {
setDiff(newSql)
setProposedQuery(true)
onRun?.(newSql, nodeName || '', undefined, undefined, undefined, false)
} else {
setSql(newSql)
onChange?.(newSql)
onRun?.(newSql, nodeName || '')
}
}
}
)
When the AI returns SQL that differs from your current query, we set diff state and flip to proposedQuery mode. The editor then renders a PlaygroundSqlDiffEditor instead of the normal editor:
// PlaygroundSqlDiffEditor.tsx
export function PlaygroundSqlDiffEditor({
original,
modified
}: {
original: string
modified: string
}) {
useEffect(() => {
const state = EditorState.create({
doc: modified,
extensions: [
EditorState.readOnly.of(true),
EditorView.editable.of(false),
tinybirdSQL(),
diffTheme,
unifiedMergeView({
original,
highlightChanges: true,
gutter: false,
mergeControls: false
})
]
})
// ...
}, [original, modified])
}
The diff view uses CodeMirror's unifiedMergeView extension. Same editor, same Tinybird SQL syntax highlighting, but with diff semantics layered on top. Green backgrounds show additions. Red shows deletions. Changed text gets an underline.
You get three buttons:
- Reset results/Run proposed query: AI's version is executed and shows results without accepting it yet — test before committing
- Accept: Apply the changes permanently
- Reject: Discard and return to your original SQL
This workflow mirrors what developers expect from code review. You see the diff. You test it. You accept or reject. The AI suggests; you decide.
What We Learned
Naming collisions are real. When users create playgrounds, they often use generic names like "test" or "debug". Multiple users in the same workspace hitting collisions. Error messages about duplicate names. We added a function to generate unique suffixes automatically: test-abc123 instead of just test. The suffix is a short ULID fragment — unique enough to prevent collisions, short enough to stay readable.
Routes should use IDs, not names. Early on, we routed to /playgrounds/my-playground-name. It looked nice in the URL bar. But names can change. Renaming a playground broke bookmarks and shared links. We switched to /playgrounds/{id} and added name resolution on load. The URL is less pretty, but it's stable.
Testing with accessible roles beats data-testid. We added UI tests with Vitest browser mode. Our first instinct was data-testid everywhere — the standard React Testing Library approach. But accessible roles — getByRole('button', { name: 'Run' }) — turned out more stable and self-documenting. When you refactor the component tree, roles stay the same. When you rename a component, roles stay the same. It also forces you to write accessible markup: if the test can't find a button by role, maybe screen readers can't either.
Non-owners need limited edit permissions. You can see a shared playground. You can even edit the SQL and run queries — that's the whole point of sharing. But you shouldn't be able to change who else can see it. That's the owner's prerogative. The API detects when a non-owner tries to modify shared_with and ignores that field. Other fields get updated normally. Ownership stays clear.
Auto-save needs debouncing. SQL changes save automatically — no explicit save button. But saving on every keystroke hammers the API. We debounce writes with a 500ms delay. Type, pause, save. It feels instant to users but keeps API traffic reasonable.
Try It
Playgrounds is available now in Forward. Open your workspace, look for it in the navigation, and start writing SQL.
No terminal. No pipe files. Just queries.
