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.