opensearch-py
opensearch-py is the official Python client for OpenSearch, the Apache 2.0 fork of Elasticsearch that AWS and the Linux Foundation maintain. It started as a fork of elasticsearch-py 7.x, so the shape is familiar: an OpenSearch object with sub-clients like .indices, .cat, and .cluster, request bodies passed as plain dicts, and helpers for bulk indexing and scrolling. What it adds over its ancestor is SigV4 signing for Amazon OpenSearch Service and Serverless, plus coverage of OpenSearch-only plugin APIs for k-NN, alerting, index management, and ML Commons. The package name on PyPI is opensearch-py but you import opensearchpy.
If you are on OpenSearch or Amazon OpenSearch Service this is the client, and its SigV4 and plugin coverage are the reason it exists. Just go in knowing you are getting elasticsearch-py 7.x ergonomics with a smaller community, so budget time for reading the samples directory rather than trusting search results.
Use it if
- Your cluster is OpenSearch, Amazon OpenSearch Service, or OpenSearch Serverless: elasticsearch-py runs a product check on connect and refuses to talk to any of them, so this is not really a preference
- You authenticate with AWS IAM. The built-in Urllib3AWSV4SignerAuth, RequestsAWSV4SignerAuth, and AWSV4SignerAsyncAuth classes handle SigV4 against both the es and aoss services without you assembling signed requests by hand
- You use OpenSearch plugins. k-NN vector search, ISM index policies, alerting monitors, and ML Commons all have generated client methods here that no Elasticsearch client will ever carry
- You are migrating off an old elasticsearch-py 7.x codebase: the body= dict style and connection_class plumbing survived the fork, so most call sites move over with an import change rather than a rewrite
- Your cluster is actually Elasticsearch. Point this client at Elasticsearch 8 or 9 and you are relying on an API surface frozen at the 7.x fork point; use elasticsearch instead
- You want the ergonomics of a current client. Everything still goes through body= dicts, there is no per-request .options() object, transport tuning happens through connection_class and pool_maxsize, and the type hints are thinner than Elastic's generated ones
- You are copying code from the internet or from older tutorials. Version 3.0 made every generated API argument keyword-only, so client.indices.create(index_name, body=...) now raises TypeError; the project's own USER_GUIDE still shows the positional form in places
- You care about install weight. Version 3.2.0 added a hard dependency on opensearch-protobufs as groundwork for a gRPC transport that is not in a release yet, so every install pulls protobuf machinery it will not use
- You want a large community behind the code. Around 469 stars and roughly 83 open issues on a small maintainer team, next to about 12.5M weekly downloads, tells you most usage is infrastructure that inherited the dependency rather than developers choosing it
- The search need is modest. OpenSearch is still a JVM cluster to size, shard, and back up; Postgres full text search, Meilisearch, or Typesense will cover site search with a fraction of the operational load
Setup reality
pip install opensearch-py is quick, but the default install gives you the urllib3 connection only; async needs pip install opensearch-py[async] for aiohttp, and IAM signing needs boto3, which the package deliberately does not depend on. Connection setup is the fiddly part. Hosts are usually passed as a list of dicts with separate host and port keys rather than a URL, and TLS is configured with the use_ssl, verify_certs, ca_certs, and ssl_show_warn flags instead of a scheme. A local development cluster ships a self-signed certificate, so people set verify_certs=False, forget, and ship it. For AWS you must pair the right signer with the right connection class: Urllib3AWSV4SignerAuth with Urllib3HttpConnection, RequestsAWSV4SignerAuth with RequestsHttpConnection, AWSV4SignerAsyncAuth with AsyncHttpConnection, and mixing them produces 403s that look like permissions problems. Finally, remember the import is opensearchpy while the install name is opensearch-py.
Patterns
Connect to a cluster with basic auth and TLScreate-a-client
from opensearchpy import OpenSearch
client = OpenSearch(
hosts=[{"host": "localhost", "port": 9200}],
http_auth=("admin", os.environ["OPENSEARCH_PASSWORD"]),
use_ssl=True,
verify_certs=True,
ca_certs="./root-ca.pem",
pool_maxsize=20,
)
info = client.info()
print(info["version"]["distribution"], info["version"]["number"])The install name is opensearch-py but the import is opensearchpy. Development guides often show verify_certs=False for the self-signed local certificate; that disables TLS verification entirely, so keep it out of anything shared.
Sign requests for Amazon OpenSearch Serviceaws-iam-authentication
import boto3
from opensearchpy import OpenSearch, Urllib3HttpConnection, Urllib3AWSV4SignerAuth
credentials = boto3.Session().get_credentials()
auth = Urllib3AWSV4SignerAuth(credentials, "us-west-2", "es") # "aoss" for Serverless
client = OpenSearch(
hosts=[{"host": "my-domain.us-west-2.es.amazonaws.com", "port": 443}],
http_auth=auth,
use_ssl=True,
verify_certs=True,
connection_class=Urllib3HttpConnection,
pool_maxsize=20,
)The signer must match the connection class: Urllib3AWSV4SignerAuth with Urllib3HttpConnection, RequestsAWSV4SignerAuth with RequestsHttpConnection, AWSV4SignerAsyncAuth with AsyncHttpConnection. The service string is es for managed domains and aoss for Serverless, and getting it wrong returns 403 rather than a useful message.
Create an index with settings and mappingscreate-an-index
client.indices.create(
index="movies",
body={
"settings": {"index": {"number_of_shards": 2, "number_of_replicas": 1}},
"mappings": {
"properties": {
"title": {"type": "text"},
"director": {"type": "keyword"},
"year": {"type": "integer"},
}
},
},
)Since 3.0 the index argument must be named. Older samples write client.indices.create("movies", body=...) and that now raises TypeError, which is the single most common upgrade break.
Write, read, and delete one documentindex-and-fetch-a-document
client.index(
index="movies",
id="1",
body={"title": "Moneyball", "director": "Bennett Miller", "year": 2011},
refresh=True,
)
doc = client.get(index="movies", id="1")["_source"]
client.update(index="movies", id="1", body={"doc": {"year": 2012}})
client.delete(index="movies", id="1")refresh=True makes the write immediately searchable and is fine in tests, but it forces a segment refresh per call and will wreck throughput in a loop. For updates the payload is nested under a doc key, unlike index where the body is the document itself.
Search with a bool querysearch-with-a-query-body
resp = client.search(
index="movies",
body={
"size": 10,
"from": 0,
"query": {
"bool": {
"must": [{"multi_match": {"query": "miller",
"fields": ["title^2", "director"]}}],
"filter": [{"range": {"year": {"gte": 2000}}}],
}
},
"sort": [{"year": "desc"}],
},
)
for hit in resp["hits"]["hits"]:
print(hit["_score"], hit["_source"]["title"])The whole query goes in body as a dict, including size and from, which is where this client diverges from elasticsearch-py 8 and later. Paging beyond 10000 hits is rejected; use search_after with a point in time.
Bulk index with the helpersbulk-index-documents
from opensearchpy.helpers import bulk, parallel_bulk
def actions(rows):
for row in rows:
yield {"_index": "movies", "_id": row["id"], "_source": row}
success, errors = bulk(client, actions(rows), chunk_size=500,
raise_on_error=False, request_timeout=120)
for ok, item in parallel_bulk(client, actions(rows), thread_count=4,
chunk_size=500, raise_on_error=False):
if not ok:
log.error("rejected: %s", item)parallel_bulk returns a generator and does nothing until you consume it, which is why people report that it silently indexed nothing. With raise_on_error=False you get the failures back as data instead of losing the batch to the first bad document.
Scroll through every matching documentiterate-all-documents
from opensearchpy.helpers import scan
for hit in scan(
client,
index="movies",
query={"query": {"range": {"year": {"lt": 2000}}}},
size=1000,
scroll="5m",
preserve_order=False,
):
archive(hit["_source"])Open scrolls hold segments on the cluster, so a long-running loop that also writes will pin disk usage; keep the scroll window short. preserve_order=True forces a single-shard-ordered scroll and is much slower.
Page a stable snapshot with a point in timepoint-in-time-paging
pit = client.create_pit(index="movies", params={"keep_alive": "5m"})
pit_id = pit["pit_id"]
try:
body = {
"size": 100,
"query": {"match_all": {}},
"pit": {"id": pit_id, "keep_alive": "5m"},
"sort": [{"year": "desc"}, {"_id": "asc"}],
}
resp = client.search(body=body)
hits = resp["hits"]["hits"]
if hits:
body["search_after"] = hits[-1]["sort"]
finally:
client.delete_pit(body={"pit_id": [pit_id]})With a pit in the body you must not pass index= to search, since the point in time already names the indices. Always delete the pit in a finally block; leaked ones keep segments alive until keep_alive expires.
Index and query k-NN vectorsknn-vector-search
client.indices.create(index="embeddings", body={
"settings": {"index": {"knn": True}},
"mappings": {"properties": {
"vector": {"type": "knn_vector", "dimension": 768,
"method": {"name": "hnsw", "space_type": "cosinesimil",
"engine": "faiss"}},
"text": {"type": "text"},
}},
})
resp = client.search(index="embeddings", body={
"size": 10,
"query": {"knn": {"vector": {"vector": embed("query text"), "k": 10}}},
})index.knn must be true at creation time; you cannot switch it on later without reindexing. The field type is knn_vector with dimension, not Elasticsearch's dense_vector with dims, so mappings do not port across.
Use the async clientasync-client
# pip install opensearch-py[async]
import asyncio
from opensearchpy import AsyncOpenSearch, AsyncHttpConnection
from opensearchpy.helpers import async_bulk
async def main() -> None:
client = AsyncOpenSearch(
hosts=[{"host": "localhost", "port": 9200}],
http_auth=("admin", PASSWORD),
use_ssl=True,
verify_certs=True,
ca_certs="./root-ca.pem",
connection_class=AsyncHttpConnection,
)
try:
await async_bulk(client, actions(rows))
resp = await client.search(index="movies", body={"query": {"match_all": {}}})
finally:
await client.close()
asyncio.run(main())The async extra installs aiohttp; without it the client constructs and then fails on the first request. Forgetting await client.close() leaves aiohttp sessions open and prints unclosed connector warnings at exit.
Compose queries with the Search DSLhigh-level-dsl
from opensearchpy import OpenSearch, Search, Q
search = (
Search(using=client, index="movies")
.filter("term", director="Bennett Miller")
.query(Q("match", title="moneyball") | Q("match", title="capote"))
.sort("-year")[:20]
)
search.aggs.bucket("per_year", "terms", field="year")
response = search.execute()
for hit in response:
print(hit.meta.score, hit.title)Search objects are immutable, so each method returns a new object and assigning back matters. The aggs attribute is the exception: bucket() mutates in place and returns the new bucket, not the search.
Call an endpoint the client does not wrapraw-rest-request
resp = client.transport.perform_request(
"GET",
"/_plugins/_ism/policies",
params={"pretty": "true"},
)
client.transport.perform_request(
"PUT",
"/_plugins/_ism/policies/hot-warm",
body={"policy": {"description": "rollover then warm", "states": []}},
)Useful for new plugin endpoints that the generated client has not caught up with. You lose parameter validation and the response is raw JSON, so pin the OpenSearch version you tested against.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| elasticsearch | PyPI | The cluster is Elasticsearch; this client is a 7.x-era fork and will not track Elastic's newer APIs. |
| meilisearch | PyPI | You want typo-tolerant product or site search from a single binary with no cluster to operate. |
| typesense | PyPI | You need low-latency faceted search and would rather configure one service than tune shards and JVM heap. |