dev_docs/tutorials/saved_objects_esql.mdx
SavedObjectsClientContract.esql allows you to query Saved Objects using ES|QL (Elasticsearch Query Language). It returns tabular results (columns and values) directly from Elasticsearch, which can be useful for analytics, aggregations, and cross-type queries that don't fit the find or search methods.
find and search| Method | Use case | Response format |
|---|---|---|
find | Simple filtering and pagination of saved objects | Structured SavedObject[] |
search | Complex queries using Elasticsearch Query DSL | Raw Elasticsearch search hits |
esql | Tabular queries using ES | QL syntax |
Use esql when you need ES|QL-specific features like STATS, EVAL, ENRICH, or pipe-based query composition.
pipeline conceptLike search and find, you specify saved object types as a dedicated parameter — you never need to know or write index names. The esql method resolves the correct Elasticsearch indices from the type parameter and auto-generates the FROM clause. Security filters (namespace + type restriction) are injected via the filter parameter, so you don't need WHERE type == either.
You write only the ES|QL processing pipeline — everything after FROM:
import { isResponseError } from '@kbn/es-errors';
import { MY_TYPE } from './saved_objects';
/** ...inside a route handler: */
async (ctx, req, res) => {
const core = await ctx.core;
const savedObjectsClient = core.savedObjects.client;
try {
const result = await savedObjectsClient.esql({
type: [MY_TYPE],
namespaces: ['default'],
pipeline: `| KEEP ${MY_TYPE}.title, ${MY_TYPE}.description
| SORT ${MY_TYPE}.title
| LIMIT 100`,
});
return res.ok({ body: { columns: result.columns, values: result.values } });
} catch (e) {
if (isResponseError(e)) {
log.error(JSON.stringify(e.meta.body, null, 2));
}
throw e;
}
}
To include METADATA fields on the auto-generated FROM clause, use the metadata option:
const result = await savedObjectsClient.esql({
type: [MY_TYPE],
namespaces: ['default'],
metadata: ['_id', '_source'],
// generates: FROM .kibana METADATA _id, _source | WHERE ...
pipeline: '| WHERE my_type.title LIKE "test*" | LIMIT 100',
});
See the full example in the Kibana repository at examples/saved_objects.
When interpolating user input into ES|QL pipelines, never use string concatenation. Instead, use ES|QL's native parameterization — named params (?paramName) or positional params (?) — to separate code from data at the protocol level.
?paramName (recommended for user input)Use ?paramName placeholders in the pipeline string and pass the values via the params array as { name: value } entries. This is true parameterization — the values are never interpolated into the query string, preventing injection attacks.
import type { estypes } from '@elastic/elasticsearch';
const userInput = req.body.searchTerm;
const result = await savedObjectsClient.esql({
type: ['my_type'],
namespaces: ['default'],
pipeline: '| WHERE my_type.title LIKE ?searchTerm | LIMIT 100',
// Named params are supported by ES at runtime, but the ES client TypeScript types
// only define positional params — cast through unknown to bridge the type gap.
params: [{ searchTerm: userInput }] as unknown as estypes.EsqlESQLParam[],
});
The pipeline sent to Elasticsearch will be | WHERE my_type.title LIKE ?searchTerm | LIMIT 100 — with searchTerm as a separate parameter, never interpolated into the pipeline string.
? (alternative)ES|QL also supports positional ? placeholders. Params are plain values (string, number, boolean, or null) matched by position:
const result = await savedObjectsClient.esql({
type: ['my_type'],
namespaces: ['default'],
pipeline: '| WHERE my_type.title LIKE ? | LIMIT 100',
params: [userInput],
});
kibana_system userThe ES|QL pipeline executes with the privileges of the kibana_system Elasticsearch user, which has elevated access including manage_enrich and broad index monitoring permissions. This means the pipeline can access resources that the end user may not be authorized to see.
Never inject arbitrary or untrusted user input directly into the pipeline string. If a user can control the full pipeline, they could use commands like ENRICH to join against enrich policies whose source data they would not normally have access to — this is a privilege escalation. Always construct the pipeline server-side and use parameterized values (see Safe pipeline construction) for any user-provided input.
Using ENRICH in your pipeline is perfectly fine when you control the pipeline and are enriching from a policy whose data is appropriate for all users who will see the results.
Like search (which passes index: getIndicesForTypes(types) internally), the esql method resolves the correct Elasticsearch indices from the type parameter and auto-generates the FROM clause. You never need to know the index name — it is an implementation detail handled by the saved objects system.
When you call esql(), namespace (space) and type filters are automatically injected into the filter parameter of the ES|QL request. The filter restricts results to the specified types and namespaces, so you don't need WHERE type == "..." in your pipeline. This works the same way as the search method:
spacesExtension.getSearchableNamespaces() resolves which namespaces the user can accesssecurityExtension.authorizeFind() checks RBAC permissionsfilterIf the user is not authorized to access any of the requested namespaces or types, an empty response is returned.
If you provide a filter in the options, it is merged with the security filter using { bool: { must: [securityFilter, yourFilter] } }. Your filter is never used in isolation.
Encrypted saved object attributes are handled differently depending on whether _source is present in the response:
_source (via metadata: ['_id', '_source']): The full document in _source contains all attributes needed for AAD (Additional Authenticated Data) reconstruction. Encrypted attributes are by default stripped from _source, or are decrypted in _source, using the same path as find and search, if registered with the dangerouslyExposeValue option. If decryption fails (e.g., key rotation), all encrypted attributes are stripped from _source.connector.secrets): Always replaced with null, regardless of _source decryption. These columns contain raw ciphertext that cannot be used outside the document context.For example, if a connector type has an encrypted secrets attribute:
connector.secrets column → always null_source column → contains the full document with secrets decrypted (or stripped on failure)The esql method returns the raw ES|QL response with columns and values:
{
"columns": [
{ "name": "index-pattern.title", "type": "keyword" },
{ "name": "type", "type": "keyword" }
],
"values": [
["logs-*", "index-pattern"],
["metrics-*", "index-pattern"]
]
}
STATS, EVAL, ENRICH, DISSECT, or GROKSavedObject instances with id, attributes, references - use find insteadsearch insteadfind insteadROW, SHOW, or METRICS - use the raw Elasticsearch client directly