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

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.

Verdict

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.

API stability3/5The call style has barely moved since the fork, which is stable in one sense, but the recent majors did break code: 3.0 enforced keyword-only arguments across every generated API and 3.1 renamed the DenseVector field type to KnnVector. Generated methods also shift whenever the upstream OpenSearch API specification does.
Docs3/5The USER_GUIDE plus the guides directory cover authentication, TLS, bulk, point in time, plugins, and proxies, and the runnable samples directory is genuinely useful. Against that, the hosted API reference is autogenerated with little prose, and parts of the user guide still show the positional call style that 3.0 removed.
Maintenance4/5Backed by the OpenSearch project with a documented release process, API regeneration against the upstream specification, active CVE-driven dependency work, and the repository pushed in July 2026. The queue is on the heavy side for the team size at roughly 83 open issues against 111 open issues and PRs, and 3.2.0 dates to April 2026.
Ecosystem3/5Around 12.5M downloads a week, but only 469 stars, and the surrounding shelf is thin: the DSL layer is a folded-in copy of the old opensearch-dsl, and integrations in tools like LangChain treat OpenSearch as a secondary backend behind Elasticsearch.

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

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

PackageRegistryPick it when
elasticsearchPyPIThe cluster is Elasticsearch; this client is a 7.x-era fork and will not track Elastic's newer APIs.
meilisearchPyPIYou want typo-tolerant product or site search from a single binary with no cluster to operate.
typesensePyPIYou need low-latency faceted search and would rather configure one service than tune shards and JVM heap.