RSQL and FIQL Filter Operator Syntax
This page sits under Advanced Filtering Operators, part of the Query Patterns & Data Shaping Strategies reference. It covers the exact decision every team faces when they expose a single filter query parameter: how to accept RSQL/FIQL syntax, parse it into a tree you control, and turn that tree into parameterised SQL without ever concatenating user input. If you have already read Handling Complex Boolean Filtering in REST APIs, this is the grammar-level detail underneath that pattern.
The Symptom That Leads Here
You start with ?status=active&min_price=10. Then product asks for “price between 10 and 50, OR any item tagged clearance”. A flat bag of query parameters cannot express OR, ranges, or nested groups. Teams reach for RSQL/FIQL — a compact, URL-friendly filter language — at exactly this point:
| Signal | What it means |
|---|---|
Clients ask for OR across different fields |
Flat key=value pairs cannot express disjunction |
| Range and set membership queries multiply parameter names | min_x, max_x, x_in sprawl per field |
| Every new filter needs a code change and a redeploy | The filter surface is hard-coded, not expression-driven |
A junior dev proposes building the WHERE clause with string concatenation |
Injection risk about to enter the codebase |
RSQL/FIQL solves the first three. The fourth is the trap this page exists to close: an expressive filter language multiplies the attack surface unless every expression is validated against an allowlist and bound as parameters.
The Grammar in One Table
FIQL (Feed Item Query Language, RFC 6266-era draft) defines the base. RSQL is a superset with friendlier aliases. Implement RSQL and you accept both.
| Operator | Meaning | Example |
|---|---|---|
== |
equals | status==active |
!= |
not equals | status!=archived |
=gt= / =ge= |
greater than / or equal | price=gt=10 |
=lt= / =le= |
less than / or equal | price=le=50 |
=in= |
in set | status=in=(active,trial) |
=out= |
not in set | region=out=(eu,uk) |
; (or and) |
logical AND | price=gt=10;status==active |
, (or or) |
logical OR | tag==clearance,price=lt=5 |
( … ) |
grouping | (a==1,b==2);c==3 |
Precedence matters: ; (AND) binds tighter than , (OR), so a==1,b==2;c==3 parses as a==1 OR (b==2 AND c==3). Parentheses override this. Getting precedence wrong is the single most common correctness bug, which is why you parse to an explicit tree rather than translating token-by-token.
The Contract: Declaring the Filter Parameter
Expose the filter as one opaque string parameter and document the allowed fields in the schema description. The OpenAPI 3.1 fragment:
# openapi/paths/products.yaml (OpenAPI 3.1.0)
openapi: 3.1.0
info:
title: Catalog API
version: 1.0.0
paths:
/v1/products:
get:
operationId: listProducts
parameters:
- name: filter
in: query
required: false
schema:
type: string
description: >
RSQL/FIQL filter expression. Allowed selectors: status, price,
region, tag, created_at. Operators: ==, !=, =gt=, =ge=, =lt=,
=le=, =in=, =out=. Combine with ; (AND) and , (OR).
example: "price=ge=10;price=le=50,tag==clearance"
responses:
"200":
description: Matching products
"400":
description: Malformed or disallowed filter expression
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
components:
schemas:
Problem:
type: object
properties:
type: { type: string }
title: { type: string }
detail: { type: string }
invalid_selector: { type: string }
Keep the allowed selectors in the description for humans, but enforce them in code — the schema string type cannot validate RSQL structure, so validation is your job.
Step-by-Step: Parse, Validate, Map
Step 1 — Parse to an AST
Do not evaluate tokens as you read them. Build a tree of two node kinds: Comparison { selector, operator, args } and Logical { op: 'AND' | 'OR', children }. A tree makes precedence explicit and lets validation walk every node before a single byte reaches the database.
Node (TypeScript):
type Comparison = {
kind: 'comparison';
selector: string;
operator: string;
args: string[];
};
type Logical = { kind: 'logical'; op: 'AND' | 'OR'; children: Ast[] };
type Ast = Comparison | Logical;
const OPERATORS = new Set(['==', '!=', '=gt=', '=ge=', '=lt=', '=le=', '=in=', '=out=']);
// A small recursive-descent parser. In production prefer a maintained
// library such as @rsql/parser; this shows the shape of the AST.
export function parse(input: string): Ast {
let pos = 0;
const orParts: Ast[] = [];
let andParts: Ast[] = [];
let token = '';
const flushComparison = () => {
const m = token.match(/^([\w.]+)(==|!=|=gt=|=ge=|=lt=|=le=|=in=|=out=)(.+)$/);
if (!m) throw new FilterError(`Cannot parse comparison: ${token}`);
const [, selector, operator, rawArgs] = m;
const args = rawArgs.replace(/^\(|\)$/g, '').split(',').map((s) => s.trim());
andParts.push({ kind: 'comparison', selector, operator, args });
token = '';
};
for (; pos < input.length; pos++) {
const ch = input[pos];
if (ch === ';') { flushComparison(); }
else if (ch === ',' && !token.includes('=in=') && !token.includes('=out=')) {
flushComparison();
orParts.push(andParts.length === 1 ? andParts[0] : { kind: 'logical', op: 'AND', children: andParts });
andParts = [];
} else { token += ch; }
}
flushComparison();
orParts.push(andParts.length === 1 ? andParts[0] : { kind: 'logical', op: 'AND', children: andParts });
return orParts.length === 1 ? orParts[0] : { kind: 'logical', op: 'OR', children: orParts };
}
class FilterError extends Error {}
Python:
import re
from dataclasses import dataclass
OPERATORS = {"==", "!=", "=gt=", "=ge=", "=lt=", "=le=", "=in=", "=out="}
_COMPARISON = re.compile(r"^([\w.]+)(==|!=|=gt=|=ge=|=lt=|=le=|=in=|=out=)(.+)$")
@dataclass
class Comparison:
selector: str
operator: str
args: list[str]
@dataclass
class Logical:
op: str # "AND" | "OR"
children: list
class FilterError(ValueError):
pass
def _comparison(token: str) -> Comparison:
m = _COMPARISON.match(token)
if not m:
raise FilterError(f"Cannot parse comparison: {token}")
selector, operator, raw = m.groups()
args = [a.strip() for a in raw.strip("()").split(",")]
return Comparison(selector, operator, args)
def parse(expr: str):
# Semicolon binds tighter (AND) than comma (OR).
or_groups = re.split(r"(?<![=])[,]", expr) # naive: real code tracks (...) depth
parsed = []
for group in or_groups:
ands = [_comparison(t) for t in group.split(";")]
parsed.append(ands[0] if len(ands) == 1 else Logical("AND", ands))
return parsed[0] if len(parsed) == 1 else Logical("OR", parsed)
For anything beyond a prototype, use a maintained grammar library (@rsql/parser in Node, rsql-parser/fiql-parser in Python). The value here is understanding the tree you get back.
Step 2 — Validate against allowlists
This is the security boundary. Two allowlists: one for selectors (mapped to real columns) and one for operators per selector type. Walk the AST and reject anything not listed. Reject before SQL generation.
type FieldSpec = { column: string; ops: Set<string> };
const FIELDS: Record<string, FieldSpec> = {
status: { column: 'status', ops: new Set(['==', '!=', '=in=', '=out=']) },
price: { column: 'price_cents', ops: new Set(['=gt=', '=ge=', '=lt=', '=le=', '==']) },
tag: { column: 'tag', ops: new Set(['==', '=in=']) },
region: { column: 'region', ops: new Set(['==', '!=', '=in=', '=out=']) },
};
export function validate(node: Ast): void {
if (node.kind === 'logical') { node.children.forEach(validate); return; }
const spec = FIELDS[node.selector];
if (!spec) throw new FilterError(`Unknown filter field: ${node.selector}`);
if (!spec.ops.has(node.operator))
throw new FilterError(`Operator ${node.operator} not allowed on ${node.selector}`);
}
Because the column name comes from FIELDS, not from the request, a client can never reach an unlisted column or a -- comment. The selector status maps to the real column status, but price maps to price_cents — the internal name is never exposed.
Step 3 — Map to parameterised SQL
Walk the validated tree and emit placeholders plus a parallel array of bound values. The operator lookup table is fixed server-side, so no operator text from the request ever reaches the SQL string.
const SQL_OP: Record<string, string> = {
'==': '=', '!=': '!=', '=gt=': '>', '=ge=': '>=', '=lt=': '<', '=le=': '<=',
};
export function toSql(node: Ast, params: unknown[]): string {
if (node.kind === 'logical') {
const joiner = node.op === 'AND' ? ' AND ' : ' OR ';
return '(' + node.children.map((c) => toSql(c, params)).join(joiner) + ')';
}
const col = FIELDS[node.selector].column; // safe: from allowlist
if (node.operator === '=in=' || node.operator === '=out=') {
const holders = node.args.map((a) => { params.push(a); return `$${params.length}`; });
return `${col} ${node.operator === '=in=' ? 'IN' : 'NOT IN'} (${holders.join(',')})`;
}
params.push(node.args[0]);
return `${col} ${SQL_OP[node.operator]} $${params.length}`;
}
// Usage
const ast = parse(req.query.filter as string);
validate(ast);
const params: unknown[] = [];
const where = toSql(ast, params);
await db.query(`SELECT * FROM products WHERE ${where} LIMIT 100`, params);
The column names are drawn from a constant map and the values are always $n placeholders — the two facts that together make injection structurally impossible.
RFC and Standard Alignment
| Standard | Relevance to filter parsing |
|---|---|
FIQL (IETF draft draft-nottingham-atompub-fiql) |
Defines the base grammar: =gt=, =lt=, ;, , and the selector/argument shape |
| RSQL (community superset) | Adds ==, !=, =in=, =out= aliases; the de-facto standard most libraries implement |
| RFC 3986 §3.4 | Query component encoding — ; and , are legal, but percent-encode reserved characters in argument values |
| RFC 9457 (Problem Details) | The 400 body format returned for a malformed or disallowed expression |
RSQL is not an IETF RFC; treat it as a well-adopted convention and pin the exact operator set in your OpenAPI description so clients and server agree.
Caching and Safety Implications
A GET with a filter parameter is safe and idempotent, so it is cacheable — but the cache key must include the normalised filter string. price=ge=10;status==active and status==active;price=ge=10 are semantically identical yet produce different cache keys unless you canonicalise the AST (sort children, lowercase operators) before serving from or writing to cache. Emit Cache-Control: private, max-age=30 for personalised result sets; unbounded filters over large tables should also carry a LIMIT to bound work, echoing the statelessness and caching strategies applied across the API. Reject filters whose AST exceeds a node-count or depth budget with 400 to prevent a pathological a==1,a==2,a==3,… from generating a thousand-term WHERE clause.
SDK and Codegen Downstream Effect
Because the filter is a single string in the contract, generated clients type it as filter?: string — they cannot build expressions for the user. Ship a small builder alongside the SDK so consumers get type safety instead of hand-concatenating operators:
// Hand-written companion to the generated client
export const f = {
eq: (field: string, v: string) => `${field}==${v}`,
gte: (field: string, v: number) => `${field}=ge=${v}`,
lte: (field: string, v: number) => `${field}=le=${v}`,
and: (...parts: string[]) => parts.join(';'),
or: (...parts: string[]) => parts.join(','),
};
// f.and(f.gte('price', 10), f.lte('price', 50)) -> "price=ge=10;price=le=50"
const params = { filter: f.or(f.eq('tag', 'clearance'), f.lte('price', 5)) };
This keeps the wire format opaque in the OpenAPI spec while giving SDK users a discoverable, mistake-resistant API.
Common Mistakes
| Mistake | Correct approach |
|---|---|
Building the WHERE clause by string-concatenating parsed values |
Emit $n placeholders and pass values as bound parameters |
| No field allowlist — any selector reaches a column | Map public selectors to internal columns via a fixed table; reject unknowns |
Treating , and ; at equal precedence |
Parse ; (AND) as binding tighter than , (OR); use a tree, not linear translation |
Exposing internal column names in 400 errors |
Return the public selector name and the allowed operator list only |
| Unbounded expression size | Cap AST depth and node count; reject oversized filters with 400 |
FAQ
What is the difference between RSQL and FIQL operators?
FIQL defines the base grammar: =gt=, =lt=, =ge=, =le= for comparisons and the logical separators , (OR) and ; (AND). RSQL is a superset that adds the friendlier aliases == and != for equals and not-equals, plus =in= and =out= for set membership. Every RSQL parser accepts FIQL input, so implementing RSQL gives you both. Pin the exact operator set you support in the OpenAPI description because neither is a ratified IETF standard.
How do I stop RSQL filters from causing SQL injection?
Never interpolate parsed values into the SQL string. Parse the filter into an AST, validate every selector against a field allowlist and every operator against an operator allowlist, then emit placeholders ($1, $2, …) and pass the argument values as bound parameters. The driver escapes them, so 1; DROP TABLE products becomes a harmless string literal. Column names come from a server-side constant map, never from the request, which closes the second injection vector.
How should the API respond to an invalid filter operator?
Return 400 Bad Request with an RFC 9457 problem+json body that names the offending selector or operator and lists the allowed alternatives, so the client can self-correct. Do not echo the raw SQL error or the internal column name — use the public field name from the schema. For a disallowed field, invalid_selector in the body tells the caller exactly which token to fix.
Related
- Advanced Filtering Operators — the section on structured filter operators that frames this grammar-level guide
- Query Patterns & Data Shaping Strategies — the parent reference covering filtering, sorting, pagination, and projection
- Handling Complex Boolean Filtering in REST APIs — nesting AND/OR groups and precedence beyond the base operator set
- Encoding Opaque Cursor Tokens with Base64URL — the sibling technique for safely round-tripping opaque query state
- Handling Invalid Sort Field Errors — the same allowlist-and-reject discipline applied to sort parameters