Filter, sort, and limit logs
SELECT: Choose which fields to retrieve.FROM: Specify the data source: a table function or a subquery.WHERE: Define conditions to filter the data.GROUP BY: Group rows for aggregation.HAVING: Filter aggregated results.TABLESAMPLE: Randomly sample a subset of the filtered data (rate or count-based).ORDER BY: Set the order of results (ASCorDESC).LIMIT: Control result size.OFFSET '<CURSOR_TOKEN>': Pagination token from the previous query response. It must be a string literal cursor token, used with cursor-compatible sorts such as_pagination_keyor_xact_id.SETTINGS: Pass per-query hints to the execution engine.
SELECT
SELECT lets you choose specific fields, compute values, or use * to retrieve every field. Use SELECT alone to retrieve individual records, or combine it with GROUP BY to aggregate results. Both work with all data shapes (spans, traces, and summary).
Implicit aliasing: Multi-part identifiers like
metadata.model automatically create implicit aliases using their last component (e.g., model), which you can use in WHERE, ORDER BY, and GROUP BY clauses when unambiguous. See Field access for details.SELECT clause. This query returns metadata.model, whether metrics.tokens is greater than 1000, and a quality indicator of either “high” or “low” depending on whether or not the Factuality score is greater than 0.8.
SELECT clause to transform values and create meaningful aliases for your results. This query extracts the day the log was created, the hour, and a Factuality score rounded to 2 decimal places.
GROUP BY for aggregations
Instead of a simple SELECT, you can use SELECT ... GROUP BY to group and aggregate data. This query returns a row for each distinct model with the day it was created, the total number of calls, the average Factuality score, and the latency percentile.
count(expr): number of rowscount_distinct(expr): number of distinct valuescount_if(expr): number of rows whereexpris truesum(expr): sum of numeric valuesavg(expr): mean (average) of numeric valuesmin(expr): minimum valuemax(expr): maximum valueany_value(expr): an arbitrary non-null value from the group for the given expressionpercentile(expr, p): a percentile wherepis between 0 and 1GROUPING(expr): returns1ifexpris a rolled-up dimension in the currentROLLUP/GROUPING SETSrow,0if it is a real value
LIMIT works with GROUP BY queries to restrict the number of grouped results returned. When combined with ORDER BY, rows are sorted before limiting. See LIMIT for examples.ROLLUP and GROUPING SETS
ROLLUP and GROUPING SETS extend GROUP BY to produce multiple levels of aggregation in a single query, eliminating the need for several GROUP BY queries.
ROLLUP produces a row for each grouping level: individual groups, subtotals for each prefix of the column list, and a grand total. GROUP BY ROLLUP(model, date) generates groups for (model, date), (model), and () (the grand total). Rolled-up dimensions appear as NULL in the output.
GROUPING SETS lets you specify exactly which grouping combinations to compute. Each set is one aggregation level. The example below is equivalent to the ROLLUP query above, but makes the groupings explicit.
GROUPING(expr) returns 1 when expr is a rolled-up dimension in the current row (meaning this row is a subtotal or grand total that aggregated over that dimension), and 0 when it holds a real group key value. Use it to label or filter subtotal rows.
HAVING for filtering aggregations
HAVING filters the results after aggregation, letting you narrow down grouped data based on aggregate values. Use HAVING with GROUP BY when you need to filter by aggregated metrics like counts, averages, or sums.
This query returns models with high average scores:
WHERE and HAVING to filter both before and after aggregation. This query filters individual logs before grouping, then filters the aggregated results:
HAVING supports the same operators and aggregate functions as other clauses. You can reference aggregated values by their alias or by repeating the aggregate expression.
FROM
The FROM clause identifies where the records are coming from.
Data sources
TheFROM clause accepts these table functions. Each accepts one or more IDs. Most also accept one or more object names, which Braintrust resolves to IDs before running the query.
The
project_* and org_* functions return all objects of that type for a project or organization. audit_logs returns all audit logs for an organization. See Audit logging for the available fields and events.
Querying by name
When you pass a name instead of a UUID, Braintrust looks up the matching object and substitutes its ID before running the query. The Query by name column above shows which functions support this.Subqueries
A subquery uses the result of an inner query as the data source for an outer query:span_filter, trace_filter, ANY_SPAN(), FILTER_SPANS()) is only supported in the innermost query, where it runs against raw data.
span_filterandtrace_filterare not supported on subquery sources.ANY_SPAN()andFILTER_SPANS()are not supported in the outerWHEREclause when the source is a subquery.HAVING(final_filter) is supported.
Data shapes
You can add an optional parameter to theFROM clause that defines how the data is returned. The options are spans (default), traces, and summary.
spans
spans returns individual spans that match the filter criteria. This example returns 10 LLM call spans that took more than 0.2 seconds to use the first token.
traces
traces returns all spans from traces that contain at least one matching span. This is useful when you want to see the full context of a specific event or behavior, for example if you want to see all spans in traces where an error occurred.
This example returns all spans for a specific trace where one span in the trace had an error.
summary
summary provides trace-level views of your data by aggregating metrics across all spans in a trace. This shape is useful for analyzing overall performance and comparing results across experiments.
The summary shape can be used in two ways:
- Individual trace summaries (using
SELECT): Returns one row per trace with aggregated span metrics. Use this to see trace-level details. Example: “What are the details of traces with errors?” - Aggregated trace analytics (using
GROUP BY): Groups multiple traces and computes statistics. Use this to analyze patterns across many traces. Example: “What’s the average cost per model per day?”
- Individual trace summaries
- Aggregated trace analytics
Use Summary rows include some aggregated metrics and some preview fields that show data from the root span of the trace.The following fields are aggregated metrics across all spans in the trace. Nested fields are accessed using dot notation (for example,
SELECT with the summary shape to retrieve individual traces with aggregated metrics. This is useful for inspecting specific trace details, debugging issues, or exporting trace-level data.This example returns 10 summary rows from the project logs for ‘my-project-id’:metrics.prompt_tokens, not prompt_tokens).scores: object of all scores averaged across all spans. Access individual scores asscores.<score_name>(for example,scores.Factuality).metrics: object of aggregated metrics across all spans. Fields below are accessed asmetrics.<field_name>.metrics.prompt_tokens: total number of prompt tokens used.metrics.completion_tokens: total number of completion tokens used.metrics.prompt_cached_tokens: total number of cached prompt tokens used.metrics.prompt_cache_creation_tokens: total number of tokens used to create cache entries.metrics.prompt_cache_creation_5m_tokens: total number of tokens used to create cache entries with a 5-minute time to live.metrics.prompt_cache_creation_1h_tokens: total number of tokens used to create cache entries with a 1-hour time to live.metrics.total_tokens: total number of tokens used (prompt + completion).metrics.estimated_cost: total estimated cost of the trace in US dollars (prompt + completion costs).metrics.llm_calls: total number of LLM calls.metrics.tool_calls: total number of tool calls.metrics.errors: total number of errors (LLM + tool errors).metrics.llm_errors: total number of LLM errors.metrics.tool_errors: total number of tool errors.metrics.start: Unix timestamp of the first span start time.metrics.end: Unix timestamp of the last span end time.metrics.duration: wall-clock elapsed time of the trace in seconds, from the earliest span start to the latest span end.metrics.llm_duration: sum of all durations across LLM spans in seconds.metrics.time_to_first_token: the average time to first token across LLM spans in seconds.
span_type_info: object with span type info. Some fields are aggregated across all spans, others reflect attributes from the root span. Fields below are accessed asspan_type_info.<field_name>.span_type_info.cached: true only if all LLM spans were cached.span_type_info.has_error: true if any span had an error.
input, output, expected, error, and metadata.For example, to select nested metric fields alongside top-level preview fields:In the
summary shape, WHERE scores.foo evaluates at the span level, returning traces where at least one span matches the condition. This is usually correct, since scorers typically run once per span. To filter by the averaged score across all spans in a trace, use HAVING avg(scores.foo). For example, HAVING avg(scores.Factuality) > 0.8 returns traces where the average Factuality score exceeds 0.8.WHERE
The WHERE clause lets you specify conditions to narrow down results. It supports a wide range of operators and functions, including complex conditions.
This example WHERE clause only retrieves data where:
- Factuality score is greater than 0.8
- model is “gpt-4”
- tag list contains “triage” (exact array membership with
IN) - input contains the word “question” (case-insensitive)
- created date is later than January 1, 2024
- more than 1000 tokens were used or the data being traced was made in production
Single span filters
By default, each returned trace includes at least one span that matches all filter conditions. UseANY_SPAN() to wrap any filter expression and find traces where at least one span matches the specified condition.
Single span filters work with the traces and summary data shapes.
ANY_SPAN() can be combined with GROUP BY to aggregate traces based on span-level conditions. This is useful for analyzing patterns across traces that contain specific types of spans. See Analyze based on tags and scores, Analyze based on tags, and Analyze traces with span filters for examples.
By default,
ANY_SPAN() matches against all spans in a trace. To restrict matching to only root spans, add is_root to the condition: ANY_SPAN(is_root AND error IS NOT NULL).ANY_SPAN() supports one level of nesting. Nested calls are flattened, which allows query builders and the Braintrust UI to compose filters by wrapping conditions in ANY_SPAN(). For example:
ANY_SPAN(ANY_SPAN(ANY_SPAN(...)))) is not supported. NOT ANY_SPAN() does not support multiple span filter clauses — for example, NOT ANY_SPAN(ANY_SPAN(a) AND ANY_SPAN(b)) is not supported.
Matching spans filters
WhileANY_SPAN() helps you find traces you care about, matching spans filters let you filter spans within the traces you’ve already found. Use FILTER_SPANS() to return only the matching spans from those traces, rather than entire traces. This is analogous to trace_filter in BTQL.
Matching spans filters work with the traces and summary data shapes. On the spans shape, FILTER_SPANS() acts as a no-op wrapper.
FILTER_SPANS() with other filter conditions:
Full-text search
UseMATCH to search a specific field for exact word matches, or search() to search across all text fields at once.
search() is equivalent to writing input MATCH query OR output MATCH query OR ... for each text field. When log search optimization is enabled, search() also benefits from bloom filter acceleration to skip irrelevant segments. Enabling shingled search optimization extends this acceleration to multi-word and phrase queries.
Pattern matching
SQL supports the% wildcard for pattern matching with LIKE (case-sensitive) and ILIKE (case-insensitive).
The % wildcard matches any sequence of zero or more characters.
Time intervals
SQL supports intervals for time-based operations. This query returns all project logs from ‘my-project-id’ that were created in the last day.ORDER BY
The ORDER BY clause determines the order of results. The options are DESC (descending) and ASC (ascending) on a numerical field. You can sort by a single field, multiple fields, or computed values.
SAMPLE
TABLESAMPLE (or the shorter SAMPLE alias) randomly samples a subset of the filtered data. Use it to work with a representative slice of a large dataset without scanning every row.
Two forms are supported:
- Percent-based:
TABLESAMPLE n PERCENT— samples approximatelyn% of rows (0–100). - Row-based:
TABLESAMPLE n ROWSorTABLESAMPLE n— samples approximatelynrows.
SEED (seed) clause makes sampling deterministic. Without a seed, each query returns a different random sample.
SAMPLE is part of the table reference in the FROM clause, not a post-filter clause. The percentage or row count applies to the full table before WHERE filtering. TABLESAMPLE BERNOULLI, TABLESAMPLE SYSTEM, and other named sampling methods are not supported.PIVOT and UNPIVOT
PIVOT and UNPIVOT are advanced operations that transform your results for easier analysis and comparison. Both SQL and BTQL syntax support these operations.
PIVOT
PIVOT transforms rows into columns, which makes comparisons easier by creating a column for each distinct value. This is useful when comparing metrics across different categories, models, or time periods.
Structure:
- The pivot column must be a single identifier (e.g.,
metadata.model) - Must include at least one aggregate measure (e.g.,
SUM(value),AVG(score)) - Only
IN (ANY)is supported (explicit value lists, subqueries,ORDER BY, andDEFAULT ON NULLare not supported) SELECTlist must include the pivot column, all measures, and allGROUP BYcolumns (or useSELECT *)
metadata.model with a model named “gpt-4” for measure avg_score, the column becomes gpt-4_avg_score. When using aliases, the alias replaces the measure name in the output column.
Single aggregate - pivot one metric across categories:
PIVOT with GROUP BY for multi-dimensional analysis:
SELECT * - automatically includes all required columns:
UNPIVOT
UNPIVOT transforms columns into rows, which is useful when you need to analyze arbitrary scores and metrics without specifying each field name in advance. This is helpful when working with dynamic sets of metrics or when you want to normalize data for aggregation.
Key-value unpivot - transforms an object into rows with key-value pairs:
When using key-value unpivot, the source column must be an object (e.g.,
scores). When using array unpivot with _, the source column must be an array (e.g., tags)._ as the name column:
UNPIVOT operations to expand multiple columns:
UNPIVOT with GROUP BY to aggregate across unpivoted rows:
LIMIT and cursors
LIMIT
The LIMIT clause controls the size of the result in number of records.
LIMIT with GROUP BY, it restricts the number of grouped results returned. This is useful for getting top-N results after aggregation. When combined with ORDER BY, rows are sorted before limiting.
Cursors for pagination
Cursors are supported in both SQL and BTQL queries. Cursors are automatically returned in query responses. After an initial query, pass the returned cursor token in the follow-on query. When a cursor has reached the end of the result set, thedata array will be empty, and no cursor token will be returned.
In SQL syntax, pass cursor tokens using OFFSET '<CURSOR_TOKEN>'. Numeric offsets are not supported. For cursor pagination, use cursor-compatible sorts such as _pagination_key (recommended) or _xact_id.
SETTINGS
The SETTINGS clause passes per-query hints to the Brainstore execution engine. It appears at the end of the query. Multiple options can be set in a single clause, separated by commas.
The following options are supported:
Field access
SQL provides flexible ways to access nested data in arrays and objects:Array indices are 0-based, and negative indices count from the end (-1 is the last element).
json_extract function to access values within it. The path is a JSONPath expression that supports nested object keys, array indexing, and an optional $ root prefix:
Implicit aliasing
When you reference multi-part identifiers (e.g.,metadata.category), SQL automatically creates an implicit alias using the last component of the path (e.g., category). This allows you to use the short form in your queries when unambiguous.
-
Ambiguity prevention: If multiple fields share the same last component (e.g.,
metadata.nameanduser.name), the short formnamebecomes ambiguous and cannot be used. You must use the full path instead. -
Top-level field priority: Top-level fields take precedence over nested fields. If you have both
idandmetadata.id, the short formidrefers to the top-level field. -
Explicit aliases override: When you provide an explicit alias (e.g.,
metadata.category AS cat), the implicit alias is disabled and you must use either the explicit alias or the full path. -
Duplicate alias detection: SQL will detect and reject queries with duplicate aliases in the SELECT list, whether explicit or implicit. For example,
SELECT id, user.number AS idwill raise an error.
Next steps
- Run queries from the sandbox, CLI, or API in the SQL overview.
- Look up syntax in Functions and operators.
- Browse copy-and-run patterns in Example queries.
- Write correct, fast queries with SQL best practices.