A minimalist Clojure library for Elasticsearch and OpenSearch REST APIs.
| Engine | Versions | Status |
|---|---|---|
| Elasticsearch | 7.x | ✅ Full Support |
| OpenSearch | 2.x, 3.x | ✅ Full Support |
| Elasticsearch | 5.x, 6.x | ⚠️ Deprecated (until 0.4.9) |
[threatgrid/ductile "0.6.0"]
(require '[ductile.conn :as es-conn])
;; Connect to Elasticsearch (default engine)
(def es-conn (es-conn/connect {:host "localhost"
:port 9200
:version 7
:protocol :http
:auth {:type :basic-auth
:params {:user "elastic" :pwd "password"}}}))
;; Connect to OpenSearch - just specify :engine
(def os-conn (es-conn/connect {:host "localhost"
:port 9200
:engine :opensearch ; ← Specify OpenSearch
:version 2
:protocol :http
:auth {:type :basic-auth
:params {:user "admin" :pwd "password"}}}))
Connection Parameters:
| Parameter | Required | Default | Description |
|---|---|---|---|
:host | ✅ | - | Hostname or IP address |
:port | ✅ | - | Port number |
:engine | ❌ | :elasticsearch | Engine type (:elasticsearch or :opensearch) |
:version | ❌ | 7 | Major version number |
:protocol | ❌ | :http | Protocol (:http or :https) |
:timeout | ❌ | 30000 | Request timeout in milliseconds |
:auth | ❌ | none | Authentication configuration |
Ductile supports multiple authentication methods:
{:type :basic-auth
:params {:user "username" :pwd "password"}}
{:type :api-key
:params {:id "key-id"
:api-key "key-secret"}}
{:type :oauth-token
:params {:token "your-token"}}
{:type :bearer
:params {:token "your-token"}}
{:type :headers
:params {:authorization "ApiKey base64-encoded-key"}}
Ductile can automatically detect the engine type and version:
(require '[ductile.capabilities :as cap])
;; Auto-detect engine and version
(cap/verify-connection conn)
;; => {:engine :opensearch
;; :version {:major 2 :minor 19 :patch 0}}
Check what features are available for your engine:
(require '[ductile.features :as feat])
;; Check specific features
(feat/supports-ilm? conn) ; => true for ES 7+, false for OpenSearch
(feat/supports-ism? conn) ; => true for OpenSearch, false for ES
(feat/supports-data-streams? conn) ; => true for ES 7+ and OpenSearch 2+
(feat/lifecycle-management-type conn) ; => :ilm or :ism
;; Get complete feature summary
(feat/get-feature-summary conn)
;; => {:ilm false
;; :ism true
;; :data-streams true
;; :composable-templates true
;; :legacy-templates true
;; :doc-types false}
Index operations work identically on both Elasticsearch and OpenSearch:
(require '[ductile.index :as es-index])
;; Check if index exists
(es-index/index-exists? conn "my-index")
;; => false
;; Create index with configuration
(def index-config
{:settings {:number_of_shards 3
:number_of_replicas 1
:refresh_interval "1s"}
:mappings {:properties {:name {:type :text}
:age {:type :long}
:created_at {:type :date}}}
:aliases {:my-index-alias {}}})
(es-index/create! conn "my-index" index-config)
;; Manage index lifecycle
(es-index/close! conn "my-index")
(es-index/open! conn "my-index")
(es-index/delete! conn "my-index")
;; Refresh index
(es-index/refresh! conn "my-index")
;; Create composable index template (ES 7.8+, OpenSearch 1+)
(es-index/create-index-template! conn "my-template" index-config ["logs-*" "metrics-*"])
;; Get template
(es-index/get-index-template conn "my-template")
;; Delete template
(es-index/delete-index-template! conn "my-template")
;; Legacy templates also supported
(es-index/create-template! conn "legacy-template" index-config ["old-*"])
The same API works for both Elasticsearch ILM and OpenSearch ISM!
;; Define policy in ILM format (works for both engines)
(def rollover-policy
{:phases
{:hot {:min_age "0ms"
:actions {:rollover {:max_docs 10000000
:max_age "7d"}}}
:warm {:min_age "7d"
:actions {:readonly {}
:force_merge {:max_num_segments 1}}}
:delete {:min_age "30d"
:actions {:delete {}}}}})
;; Create policy - automatically transforms to ISM for OpenSearch
(require '[ductile.lifecycle :as lifecycle])
(lifecycle/create-policy! conn "my-rollover-policy" rollover-policy)
;; Get policy (returns ILM format for ES, ISM format for OpenSearch)
(lifecycle/get-policy conn "my-rollover-policy")
;; Delete policy
(lifecycle/delete-policy! conn "my-rollover-policy")
How it works:
Example transformation:
;; Input (ILM format)
{:phases {:hot {:actions {:rollover {:max_docs 100000}}}
:delete {:min_age "30d" :actions {:delete {}}}}}
;; Automatically becomes (ISM format for OpenSearch)
{:states [{:name "hot"
:actions [{:rollover {:min_doc_count 100000}}]
:transitions [{:state_name "delete"
:conditions {:min_index_age "30d"}}]}
{:name "delete"
:actions [{:delete {}}]}]
:default_state "hot"
:schema_version 1}
CRUD operations work identically on both engines:
(require '[ductile.document :as doc])
;; Create document
(doc/create-doc conn "my-index"
{:id 1
:name "John Doe"
:email "john@example.com"}
{:refresh "wait_for"})
;; Get document
(doc/get-doc conn "my-index" 1 {})
;; => {:id 1 :name "John Doe" :email "john@example.com"}
;; Update document
(doc/update-doc conn "my-index" 1
{:age 30}
{:refresh "wait_for"})
;; Delete document
(doc/delete-doc conn "my-index" 1 {:refresh "wait_for"})
;; Bulk operations
(doc/bulk-index-docs conn "my-index"
[{:id 1 :name "Alice"}
{:id 2 :name "Bob"}
{:id 3 :name "Charlie"}]
{:refresh "true"})
;; Delete by query
(doc/delete-by-query conn ["my-index"]
{:match {:status "archived"}}
{:wait_for_completion true :refresh "true"})
(require '[ductile.query :as q])
;; Simple query
(doc/query conn "my-index"
{:match {:name "John"}}
{})
;; Query with aggregations
(doc/query conn "my-index"
{:match_all {}}
{:aggs {:age_stats {:stats {:field :age}}}})
;; Using query helpers
(doc/query conn "my-index"
(q/bool {:must [{:match {:status "active"}}]
:filter [{:range {:age {:gte 18}}}]})
{:limit 100})
;; Search with filters
(doc/search-docs conn "my-index"
{:query_string {:query "active"}}
{:age 30}
{:sort {:created_at {:order :desc}}})
Data streams work on both Elasticsearch 7.9+ and OpenSearch 2.0+:
;; Create data stream
(es-index/create-data-stream! conn "logs-app")
;; Get data stream info
(es-index/get-data-stream conn "logs-app")
;; Delete data stream
(es-index/delete-data-stream! conn "logs-app")
| Feature | Elasticsearch 7 | OpenSearch 2 | OpenSearch 3 | Notes |
|---|---|---|---|---|
| Basic CRUD | ✅ | ✅ | ✅ | Full compatibility |
| Queries & Aggregations | ✅ | ✅ | ✅ | Full compatibility |
| Index Management | ✅ | ✅ | ✅ | Full compatibility |
| Index Templates | ✅ | ✅ | ✅ | Both legacy and composable |
| Data Streams | ✅ (7.9+) | ✅ | ✅ | Requires version check |
| ILM Policies | ✅ | ⚠️ Auto-transform | ⚠️ Auto-transform | Transforms to ISM |
| ISM Policies | ❌ | ✅ | ✅ | OpenSearch only |
| Rollover | ✅ | ✅ | ✅ | Full compatibility |
| Aliases | ✅ | ✅ | ✅ | Full compatibility |
⚠️ = Automatically handled via transformation layer
If your application only uses basic operations (CRUD, queries, indices), migration is as simple as:
;; Before (Elasticsearch)
(def conn (es-conn/connect {:host "es-host" :port 9200 :version 7}))
;; After (OpenSearch) - just add :engine
(def conn (es-conn/connect {:host "os-host"
:port 9200
:engine :opensearch ; ← Only change needed
:version 2}))
If you use ILM policies, no code changes are required! Policies are automatically transformed:
;; This code works for BOTH Elasticsearch and OpenSearch
(require '[ductile.lifecycle :as lifecycle])
(defn setup-lifecycle [conn]
(lifecycle/create-policy! conn "my-policy"
{:phases {:hot {:actions {:rollover {:max_docs 1000000}}}
:delete {:min_age "30d" :actions {:delete {}}}}}))
;; Works with Elasticsearch (creates ILM policy)
(setup-lifecycle es-conn)
;; Works with OpenSearch (creates ISM policy with auto-transformation)
(setup-lifecycle os-conn)
Use environment variables or configuration to switch engines:
(defn create-connection [config]
(es-conn/connect
{:host (:host config)
:port (:port config)
:engine (keyword (:engine config)) ; "elasticsearch" or "opensearch"
:version (:version config)
:auth {:type :basic-auth
:params {:user (:user config)
:pwd (:password config)}}}))
;; Configuration switches engine
(def config {:host "localhost"
:port 9200
:engine "opensearch" ; ← Switch here
:version 2
:user "admin"
:password "password"})
(def conn (create-connection config))
# Run unit tests only
lein test ductile.capabilities-test ductile.conn-test ductile.features-test ductile.lifecycle-test
# Run with Docker containers
cd containers
docker-compose up -d
# Test against all engines
DUCTILE_TEST_ENGINES=all lein test :integration
# Test against Elasticsearch only
DUCTILE_TEST_ENGINES=es lein test :integration
# Test against OpenSearch only
DUCTILE_TEST_ENGINES=os lein test :integration
(require '[ductile.conn :as es-conn]
'[clj-http.client :as client])
;; Stub requests for testing
(def conn (es-conn/connect
{:host "localhost"
:port 9200
:request-fn (fn [req]
{:status 200
:body {:acknowledged true}})}))
(def conn (es-conn/connect
{:host "localhost"
:port 9200
:request-fn (-> (fn [req]
(println "Request:" req)
(client/request req))
client/wrap-query-params)}))
Ductile automatically manages connection pooling with sensible defaults:
(try
(doc/create-doc conn "my-index" {:id 1 :name "test"} {})
(catch clojure.lang.ExceptionInfo e
(let [data (ex-data e)]
(case (:type data)
:ductile.conn/unauthorized (println "Auth failed")
:ductile.conn/invalid-request (println "Invalid request")
:ductile.conn/es-unknown-error (println "Unknown error")
(throw e)))))
Test containers are provided for local development:
cd containers
docker-compose up -d
# Services:
# - es7: Elasticsearch 7.10.1 on port 9207
# - opensearch2: OpenSearch 2.19.0 on port 9202
# - opensearch3: OpenSearch 3.1.0 on port 9203
lein testCopyright © Cisco Systems
This program and the accompanying materials are made available under the terms of the Eclipse Public License 2.0 which is available at http://www.eclipse.org/legal/epl-2.0.
For issues and feature requests, please use the GitHub issue tracker.
Can you improve this documentation? These fine people already did:
Guillaume ERETEO, Ambrose Bonnaire-Sergeant, Guillaume Erétéo & Kirill ChernyshovEdit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |