mrkeyoor.com_
Thu 06 Aug 10:57 UTC
PyPIDataupdated 06 Aug 2026

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.

Verdict

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.

API stability3/5Minor releases are additive and predictable, but the client's major version is tied to the server's, so upgrades come on Elastic's schedule rather than yours. 8.0 moved request bodies to keyword arguments, 9.0 deleted several constructor options outright, and 9.2 raised the minimum server version for the DSL module.
Docs4/5There is a getting-started guide on elastic.co, a full API reference on Read the Docs, dedicated DSL and ES|QL sections, and detailed per-release notes including a breaking changes page. What is missing is the middle layer: generated methods list dozens of parameters with one-line descriptions, so real query work still means reading the Elasticsearch REST docs alongside.
Maintenance5/5Maintained by Elastic staff with releases tracking every server minor (9.5.0 shipped 4 August 2026), the repository pushed the same week, and around 54 open issues against 61 open issues and PRs, which is a small queue for a client this large.
Ecosystem5/5About 12.5M downloads a week and the assumed dependency for anything Python that touches Elasticsearch, including LangChain and Haystack vector stores, django-elasticsearch-dsl, and Elastic's own APM tooling.

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
Skip it if

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

PackageRegistryPick it when
opensearch-pyPyPIYour cluster is OpenSearch or AWS OpenSearch Service, where this client's product check blocks you outright.
meilisearchPyPIYou want typo-tolerant site search running as a single small binary instead of a JVM cluster.
typesensePyPIYou need fast faceted search with predictable latency and a much smaller operational surface.