elasticsearch
This is Elastic's official Python client for Elasticsearch. Most of it is generated from the server's API specification, so every REST endpoint shows up as a Python method with named keyword arguments: client.search(index=..., query=...), client.indices.create(index=..., mappings=...), client.cat.indices(). On top of the raw calls it ships helpers for bulk indexing and for scrolling through large result sets, an async client, and since version 9 the old elasticsearch-dsl package is merged in as elasticsearch.dsl for people who prefer declarative document models over hand-written query dictionaries.
If you run Elasticsearch, use this client; the alternatives are all worse-maintained wrappers around the same REST API, and the bulk and DSL helpers are worth having. The real decision is upstream of the library: whether the search problem is big enough to justify operating an Elasticsearch cluster at all.
Use it if
- You already run Elasticsearch and want the client that tracks it: new server APIs appear here within a release or two, which no third-party wrapper keeps up with
- You are doing bulk ingestion and need the helpers: streaming_bulk and parallel_bulk handle chunking, retries on 429, and per-document error reporting that you would otherwise write yourself
- You want typed, declarative queries: elasticsearch.dsl gives you Document classes with mapped fields plus a Search object that composes filters, aggregations, and pagination without nesting dictionaries five levels deep
- You need async: AsyncElasticsearch mirrors the whole surface, and async_bulk and async_scan match their sync counterparts, so an async web service is not stuck making blocking calls
- You are talking to OpenSearch, AWS OpenSearch Service, or any Elasticsearch fork. The client performs a product check on connect and refuses to run against anything that does not identify as Elasticsearch; opensearch-py is the fork of this library that removes it
- You cannot control the upgrade order. Client majors are pinned to server majors: 9.x expects an Elasticsearch 9 cluster and fails against 8, and the documented order is to upgrade the server first, then the client. In a shared cluster that turns a library bump into a coordination problem
- The search problem is small. Running Elasticsearch means running a JVM cluster with heap tuning, shard planning, and snapshot policy; Postgres full text search, SQLite FTS5, Meilisearch, or Typesense cover site search and autocomplete at a fraction of the operational cost
- You want the client to teach you Elasticsearch. It is a thin generated transport: query bodies are still raw Elasticsearch JSON in dict form, mapping mistakes surface as server-side BadRequestError with the cluster's own message, and nothing validates a query before it goes over the wire
- You are pinning versions for a long-lived service. Majors have removed constructor options (timeout, maxsize, sniffer_timeout and friends went away in 9.0) and 9.2 introduced a DSL change that requires an Elasticsearch 9.1 or newer server, so staying current is real work
Setup reality
pip install elasticsearch pulls elastic-transport, python-dateutil, anyio, sniffio, and typing-extensions, with no compiled code. Async needs the extra: pip install elasticsearch[async] adds aiohttp, and importing AsyncElasticsearch without it fails at connection time rather than at import. The connection itself is where people lose an afternoon. Since version 8 the scheme is mandatory, so hosts must read https://host:9200 and not host:9200. A default local Elasticsearch generates a self-signed CA, so you either pass ca_certs pointing at http_ca.crt copied out of the container or you get certificate verify failed. Credentials go in as api_key or basic_auth, never as a URL userinfo string. Per-request settings such as request_timeout, ignore_status, and retries moved onto client.options(...), so old examples passing them straight to search() no longer apply, and body= as a single dict is deprecated in favour of keyword arguments across every API.
Patterns
Connect with an API key or basic authconnect-to-a-cluster
from elasticsearch import Elasticsearch
# self-managed, self-signed CA
client = Elasticsearch(
"https://localhost:9200",
api_key="VnVhQ2ZHY0JDZGJrU...",
ca_certs="./http_ca.crt",
)
# Elastic Cloud
client = Elasticsearch(
cloud_id="deployment:dXMtZWFzdD...",
basic_auth=("elastic", os.environ["ES_PASSWORD"]),
)
print(client.info())The scheme is required; passing localhost:9200 raises a ValueError. Against a default local install you must supply ca_certs or verify_certs=False, and the latter should never reach production.
Create an index with explicit mappingscreate-an-index-with-mappings
client.indices.create(
index="articles",
settings={"number_of_shards": 1, "number_of_replicas": 0},
mappings={
"properties": {
"title": {"type": "text", "analyzer": "english"},
"tags": {"type": "keyword"},
"published_at": {"type": "date"},
"views": {"type": "integer"},
}
},
)settings and mappings are top level keyword arguments now, not nested inside body. Mappings are close to immutable: you can add fields later but you cannot change an existing field's type without reindexing.
Write and read a single documentindex-get-update-delete
client.index(index="articles", id="a1", document={
"title": "Sharding for people in a hurry",
"tags": ["ops"],
"views": 0,
})
doc = client.get(index="articles", id="a1")["_source"]
client.update(index="articles", id="a1", doc={"views": 1})
client.update(index="articles", id="a1",
script={"source": "ctx._source.views += params.n", "params": {"n": 1}})
client.delete(index="articles", id="a1")
client.indices.refresh(index="articles")Writes are not searchable until the next refresh, about one second by default; call indices.refresh in tests or you will chase phantom failures. Use refresh='wait_for' on the write itself rather than refreshing the whole index in production.
Run a bool query with paging and sortingsearch-with-a-query
resp = client.search(
index="articles",
query={
"bool": {
"must": [{"match": {"title": "sharding"}}],
"filter": [
{"terms": {"tags": ["ops", "infra"]}},
{"range": {"published_at": {"gte": "now-30d"}}},
],
}
},
sort=[{"published_at": "desc"}],
size=20,
from_=0,
)
print(resp["hits"]["total"]["value"])
for hit in resp["hits"]["hits"]:
print(hit["_score"], hit["_source"]["title"])The parameter is from_ because from is a Python keyword. Deep paging past 10000 results is refused; switch to search_after with a point in time instead of raising index.max_result_window.
Bulk index with per-document error handlingbulk-index-documents
from elasticsearch.helpers import bulk, streaming_bulk
def actions(rows):
for row in rows:
yield {"_index": "articles", "_id": row["id"], "_source": row}
success, errors = bulk(client, actions(rows), chunk_size=500,
raise_on_error=False, request_timeout=120)
# streaming variant: constant memory, act on each result
for ok, item in streaming_bulk(client, actions(rows), max_retries=3,
raise_on_error=False):
if not ok:
log.error("rejected: %s", item)With raise_on_error=True (the default) one bad document aborts the whole run and you lose the report of which ones failed. max_retries only retries 429 rejections, not mapping errors, which will never succeed on retry.
Read every matching document without deep pagingiterate-all-documents
from elasticsearch.helpers import scan
for hit in scan(
client,
index="articles",
query={"query": {"range": {"published_at": {"lt": "now-1y"}}}},
size=1000,
scroll="5m",
preserve_order=False,
):
archive(hit["_source"])scan ignores sort unless preserve_order=True, which costs a lot of performance; leave it off unless order really matters. Each open scroll pins segments on the cluster, so keep the scroll window short and never leave one open across a long batch job.
Set timeouts, retries, and ignored statuses per callper-request-options
client.options(request_timeout=120).indices.forcemerge(index="articles")
# treat a missing index as a non-error
client.options(ignore_status=404).indices.delete(index="maybe-missing")
# extra headers or an opaque id for tracing in the slow log
client.options(
opaque_id="nightly-reindex",
headers={"X-Request-Source": "batch"},
).search(index="articles", query={"match_all": {}})These used to be per-call keyword arguments and were moved onto options() in 8.0, so any example passing request_timeout directly to search() is out of date. Client-wide defaults still belong in the Elasticsearch() constructor.
Aggregate without returning documentsaggregations
resp = client.search(
index="articles",
size=0,
query={"range": {"published_at": {"gte": "now-90d"}}},
aggs={
"by_tag": {
"terms": {"field": "tags", "size": 20},
"aggs": {"avg_views": {"avg": {"field": "views"}}},
},
"per_week": {
"date_histogram": {"field": "published_at", "calendar_interval": "week"}
},
},
)
for bucket in resp["aggregations"]["by_tag"]["buckets"]:
print(bucket["key"], bucket["doc_count"], bucket["avg_views"]["value"])size=0 skips returning hits, which is most of the cost. terms aggregations on a text field fail; they need a keyword field or fielddata enabled, and their counts are approximate on multi-shard indices.
Use the async client in an async serviceasync-client
# pip install elasticsearch[async]
import asyncio
from elasticsearch import AsyncElasticsearch
from elasticsearch.helpers import async_bulk
async def main() -> None:
client = AsyncElasticsearch("https://localhost:9200", api_key=KEY,
ca_certs="./http_ca.crt")
try:
resp = await client.search(index="articles", query={"match_all": {}})
await async_bulk(client, actions(rows))
finally:
await client.close()
asyncio.run(main())Always await client.close(); a leaked AsyncElasticsearch prints unclosed session warnings and holds sockets open. The async extra installs aiohttp, and without it construction succeeds but the first request fails.
Define documents with the built-in DSLdeclarative-documents
from elasticsearch.dsl import Document, Text, Keyword, Date, Integer, connections
connections.create_connection(hosts=["https://localhost:9200"], api_key=KEY)
class Article(Document):
title = Text(analyzer="english")
tags = Keyword()
published_at = Date()
views = Integer()
class Index:
name = "articles"
Article.init()
Article(meta={"id": "a1"}, title="Sharding", tags=["ops"], views=0).save()
hits = Article.search().filter("term", tags="ops").query("match", title="sharding")[:10]
for article in hits:
print(article.title)elasticsearch.dsl replaces the separate elasticsearch-dsl package, which is now just a shim; do not install both. Search objects are lazy and immutable, so every method returns a new object and nothing executes until you slice or iterate.
Run a kNN vector queryvector-search
client.indices.create(index="docs", mappings={"properties": {
"embedding": {"type": "dense_vector", "dims": 768, "similarity": "cosine"},
"text": {"type": "text"},
}})
resp = client.search(
index="docs",
knn={
"field": "embedding",
"query_vector": embed("how do shards work"),
"k": 10,
"num_candidates": 100,
},
query={"match": {"text": "shards"}}, # hybrid: combines with the kNN score
)The standalone /_knn_search endpoint was removed in Elasticsearch 9; knn is a search parameter now. num_candidates must be at least k and controls the recall against latency trade-off per shard.
Query with ES|QL instead of query DSLrun-esql-query
resp = client.esql.query(
query="""
FROM articles
| WHERE views > 100
| STATS total = SUM(views) BY tags
| SORT total DESC
| LIMIT 10
""",
format="json",
)
columns = [c["name"] for c in resp["columns"]]
for row in resp["values"]:
print(dict(zip(columns, row)))ES|QL returns columns and values arrays rather than hits, so nothing about the response looks like a normal search. The client also ships a Python builder under elasticsearch.esql if you would rather compose the pipeline than format a string.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| opensearch-py | PyPI | Your cluster is OpenSearch or AWS OpenSearch Service, where this client's product check blocks you outright. |
| meilisearch | PyPI | You want typo-tolerant site search running as a single small binary instead of a JVM cluster. |
| typesense | PyPI | You need fast faceted search with predictable latency and a much smaller operational surface. |