elasticsearch review
elasticsearch 9.5.0 is the official Python transport and API client for Elasticsearch. It turns Python values into REST requests, keeps a connection pool across nodes, and exposes sync and async clients plus bulk, scan, ES|QL, and `elasticsearch.dsl` helpers. It does not run a search engine or validate the meaning of most query JSON locally. Version 9.5.0 adds bindings for encryption reset and inference region policies, extends several server APIs, adds DSL doc-values options, and fixes opaque-ID propagation in `async_scan`. Our install was pure Python and shipped py.typed.
elasticsearch 9.5.0 installed in 0.3 seconds and used 10 MB across 10 packages in our sandbox, with typed APIs and no audit findings. Use it for an actual Elasticsearch cluster; if you are still choosing the search server, compare the operational cost before this official client decides the architecture for you.
We installed it
| Install | ✓ · 0.3s | 10 packages on disk · 10 MB |
| Import | ✓ | import elasticsearch in 1.14s · pure Python · py.typed · requires Python >=3.10 |
| Known vulns | 0 | (pip-audit) |
Answers from our run
Does elasticsearch install cleanly?
Yes. In a fresh container with an empty cache, pip install elasticsearch finished in 0.3s, leaving 10 packages and 10 MB on disk. pip-audit reported no known vulnerabilities.
What does elasticsearch need to run?
Python >=3.10, and nothing compiled: it is pure Python. In our run import elasticsearch succeeded in 1.14s, and the package ships py.typed for type checkers.
elasticsearch or opensearch-py: which should you use?
opensearch-py: Use it when the remote service is OpenSearch, including AWS deployments that expose OpenSearch APIs. elasticsearch 9.5.0 installed in 0.3 seconds and used 10 MB across 10 packages in our sandbox, with typed APIs and no audit findings.
When should you not use elasticsearch?
The cluster is OpenSearch or AWS OpenSearch Service. opensearch-py targets that product, while Elastic's client performs Elasticsearch-specific compatibility and product checks.
Use it if
- The server is Elasticsearch 9.x and application code needs API coverage released alongside that server line.
- A large ingestion job needs `streaming_bulk`, per-item failures, and bounded chunks rather than a hand-written HTTP loop.
- An asyncio service needs persistent Elasticsearch connections without blocking its event loop during network calls.
- Your query code benefits from the lazy Search and Document interface now included under `elasticsearch.dsl`.
- The cluster is OpenSearch or AWS OpenSearch Service. `opensearch-py` targets that product, while Elastic's client performs Elasticsearch-specific compatibility and product checks.
- Client and server major upgrades cannot be coordinated. Elastic's documented compatibility promise is strongest within a major, and full feature coverage needs the matching client release.
- You only need search over a modest product catalog and do not want to operate nodes, mappings, shards, snapshots, and refresh behavior.
- The application expects Python types to catch malformed Elasticsearch queries. Most mapping and query errors come back from the server after a request.
- The runtime is Python 3.9 or older. Version 9.5.0 requires Python 3.10 or later.
Setup reality
We installed elasticsearch 9.5.0 in a fresh Python 3.12 Bookworm container. The install finished in 0.3 seconds and occupied 10 MB across 10 packages. Its metadata declares 44 direct dependencies, and the distribution is pure Python with py.typed. import elasticsearch succeeded in 1.14 seconds. pip-audit reported zero known vulnerabilities. The package requires Python 3.10 or later and uses the Apache Software License.
The wheel contains no server. Connect with a full URL plus an API key, basic credentials, or Elastic Cloud's cloud_id. A private HTTPS cluster usually needs its CA certificate through ca_certs; disabling certificate checks turns a configuration problem into a security problem. The 9.5 client should follow the server upgrade when crossing a major, because Elastic documents complete feature support for equivalent client and server releases.
Transport options belong on the constructor or a client returned by .options(). That detail breaks copied examples which pass request_timeout, ignore_status, or opaque_id as API parameters. Reuse one client so persistent connections and node health tracking work. In async code, keep one AsyncElasticsearch for the application lifetime and call close() during shutdown. Release 9.5.0 fixes async_scan so its opaque ID reaches the underlying requests.
Bulk helpers report success per action, so inspect failed items instead of treating the request as one result. Retrying HTTP 429 can help; retrying a mapping rejection unchanged cannot. Search visibility also follows the index refresh cycle. A document accepted by index() may be available through get() before a search finds it. Tests and low-volume workflows can use refresh='wait_for', while sustained ingestion normally lets scheduled refresh handle visibility.
Patterns
Connect with an API key and CA connect-securely
import os
from elasticsearch import Elasticsearch
client = Elasticsearch(
'https://localhost:9200',
api_key=os.environ['ELASTIC_API_KEY'],
ca_certs='./http_ca.crt',
)
print(client.info()['version']['number'])The URL needs its scheme. Keep certificate verification enabled and trust the cluster's CA instead of setting `verify_certs=False`.
Open an Elastic Cloud connection connect-elastic-cloud
import os
from elasticsearch import Elasticsearch
client = Elasticsearch(
cloud_id=os.environ['ELASTIC_CLOUD_ID'],
api_key=os.environ['ELASTIC_API_KEY'],
)
print(client.cluster.health()['status'])`cloud_id` locates the deployment; the API key supplies authority. Issue that key only the index and cluster privileges this service uses.
Create explicit field mappings create-mapping
client.indices.create(
index='articles',
mappings={
'properties': {
'title': {'type': 'text', 'analyzer': 'english'},
'tags': {'type': 'keyword'},
'published_at': {'type': 'date'},
}
},
settings={'number_of_shards': 1},
)Changing an existing field type usually requires a new index and reindex. Decide which values need text analysis and which need exact keyword matching.
Wait until an indexed document is searchable index-visible-document
client.index(
index='articles',
id='post-42',
document={'title': 'Shard sizing', 'tags': ['ops']},
refresh='wait_for',
)
result = client.get(index='articles', id='post-42')
print(result['_source'])`refresh='wait_for'` adds write latency and is best reserved for tests or low-volume workflows that need immediate search visibility.
Score text and filter exact tags search-and-filter
response = client.search(
index='articles',
query={
'bool': {
'must': [{'match': {'title': 'shard sizing'}}],
'filter': [{'term': {'tags': 'ops'}}],
}
},
sort=[{'published_at': 'desc'}],
size=20,
)
for hit in response['hits']['hits']:
print(hit['_id'], hit['_source'])A term query expects an exact indexed token. Map tag-like values as keyword rather than analyzed text.
Inspect every streaming bulk result bulk-index
from elasticsearch.helpers import streaming_bulk
def actions(rows):
for row in rows:
yield {'_index': 'articles', '_id': row['id'], '_source': row}
for ok, item in streaming_bulk(
client, actions(rows), chunk_size=500, max_retries=3, raise_on_error=False
):
if not ok:
log.error('bulk item failed: %s', item)A 429 may succeed after a bounded retry. A mapping rejection needs changed data or mapping, so keep it out of an endless retry queue.
Process a large match set scan-results
from elasticsearch.helpers import scan
for hit in scan(
client,
index='articles',
query={'query': {'range': {'published_at': {'lt': 'now-1y'}}}},
scroll='2m',
):
archive(hit['_source'])A scroll holds cluster resources. Finish promptly, or use a point in time with `search_after` for stable deep pagination.
Set transport options for one request request-options
slow = client.options(request_timeout=120, opaque_id='nightly-archive')
result = slow.search(
index='articles',
query={'match_all': {}},
size=0,
)
client.options(ignore_status=404).indices.delete(index='old-preview')Current clients put transport behavior on `.options()`. `request_timeout` and `ignore_status` are not search-body parameters.
Close async connections during teardown async-client
import os
from elasticsearch import AsyncElasticsearch
async def count_articles() -> int:
client = AsyncElasticsearch(
'https://localhost:9200',
api_key=os.environ['ELASTIC_API_KEY'],
ca_certs='./http_ca.crt',
)
try:
result = await client.count(index='articles')
return result['count']
finally:
await client.close()A server should keep one async client for its lifetime and close it at shutdown, rather than create a connection pool per request.
Compose a lazy DSL search dsl-query
from elasticsearch.dsl import Search
query = (
Search(using=client, index='articles')
.query('match', title='shard sizing')
.filter('term', tags='ops')
.sort('-published_at')
)
for hit in query[:20].execute():
print(hit.meta.id, hit.title)Search objects build a request lazily. The network call happens on `execute()` or when the result is otherwise evaluated.
Count top keyword values aggregate-keywords
response = client.search(
index='articles',
size=0,
aggs={
'popular_tags': {
'terms': {'field': 'tags', 'size': 20}
}
},
)
for bucket in response['aggregations']['popular_tags']['buckets']:
print(bucket['key'], bucket['doc_count'])Terms aggregation requires a keyword-compatible field. `size=0` omits document hits when only buckets are needed.
Execute an ES|QL pipeline run-esql
response = client.esql.query(
query='''
FROM articles
| WHERE views > 100
| STATS total = SUM(views) BY tags
| SORT total DESC
| LIMIT 10
''',
format='json',
)
columns = [column['name'] for column in response['columns']]
for values in response['values']:
print(dict(zip(columns, values)))ES|QL returns column metadata and row arrays rather than search hits. Its available syntax follows the connected server version.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| opensearch-py | PyPI | Use it when the remote service is OpenSearch, including AWS deployments that expose OpenSearch APIs. |
| meilisearch | PyPI | Choose it when a smaller typo-tolerant search service meets the application requirement. |
| typesense | PyPI | Choose it for faceted application search when its simpler schema and operational model are enough. |
| elasticsearch8 | PyPI | Use the separately named 8.x package when one process must retain an Elasticsearch 8 client beside version 9. |
More data guides
numpy · fsspec · pandas · sqlalchemy · pyarrow · lxml · the whole shelf →
How this guide is made: grounded in the library's documentation, release notes, changelog, and issue history, on a fixed rubric — not a hands-on install of every release. The 50 most-downloaded entries are additionally install-verified in clean containers. Corrections: contact the desk.

