Dowser.Elasticsearch

dowser_elasticsearch is an Elixir client library for the Elasticsearch API, built on top of dowser_client. It gives you one function per Elasticsearch endpoint — named and shaped after the endpoint itself — instead of a hand-rolled query builder.

Installation

Add dowser_elasticsearch to your list of dependencies in mix.exs:

def deps do
  [
    {:dowser_elasticsearch, "~> 0.6.0"}
  ]
end

Documentation can be generated with ExDoc and published on HexDocs. Once published, the docs can be found at https://hexdocs.pm/dowser_elasticsearch.

Upgrading from 0.4? 0.5 replaces contexts with repos, moves the casting to dowser_client and casts only nine date formats — see UPGRADE_0_5.md for the migration path, and CHANGELOG.md for everything that changed. Coming from 0.1.x, start with UPGRADE_0_2.md.

Configuration

Define a repo — one module per cluster, configured under your own application and started in your supervision tree, like an Ecto.Repo:

defmodule MyApp.Search do
  use Dowser.Elasticsearch.Repo, otp_app: :my_app
end
# config/runtime.exs
config :my_app, MyApp.Search, endpoint: System.get_env("ELASTICSEARCH_URL", "http://localhost:9200")

# the repo used when a call names none
config :dowser_elasticsearch, default_repo: MyApp.Search
# application.ex
children = [MyApp.Search]

Every API function takes the cluster as its :repo option — a repo module, or [endpoint: ...] for a script — and falls back to :default_repo without one. See Dowser.Elasticsearch.Repo and Dowser.Repo for the configuration (auth, headers, TLS, :httpc profiles, an init/2 callback for secrets), checking at startup that the endpoint really is Elasticsearch (verify: true), and the HTTP verbs a repo exposes for endpoints this package doesn't cover.

Usage

Each module maps to one Elasticsearch API tag. Request bodies come first so calls pipe naturally; the index (when required) follows; everything optional goes in opts.

alias Dowser.Elasticsearch.Search

%{query: %{match: %{title: "hello"}}}
|> Search.search(index: "posts")
# {:ok, %{"hits" => %{...}}}

Search.count!(%{query: %{term: %{status: "published"}}}, index: "posts")
# %{"count" => 42, "_shards" => %{...}}

Document

alias Dowser.Elasticsearch.Document

{:ok, %{"_id" => id}} = Document.index(%{title: "hello"}, "posts")

Document.get!("posts", id)
# %{"_source" => %{"title" => "hello"}, ...}

Document.exists?("posts", id)
# true

Document.delete("posts", id)

Index

alias Dowser.Elasticsearch.Index

Index.create_index!(%{mappings: %{properties: %{title: %{type: "text"}}}}, "posts")
Index.index_exists?("posts")
# true
Index.refresh(index: "posts")
Index.delete_index("posts")

Cluster

alias Dowser.Elasticsearch.Cluster

Cluster.ping?()
# true
Cluster.health!(params: [wait_for_status: "yellow"])
# %{"status" => "yellow", "number_of_nodes" => 1, ...}
Cluster.nodes_stats!(metric: "indices", index_metric: "docs")

Cat

alias Dowser.Elasticsearch.Cat

Cat.indices!(index: "posts*", params: [s: "docs.count:desc"])
# [%{"index" => "posts", "health" => "green", "docs.count" => "42", ...}]

The cat APIs answer in JSON here, one map per row, rather than the aligned text a terminal shows — pass params: [format: "text", v: true], resp_format: :raw for that.

Health report, info and usage

alias Dowser.Elasticsearch.HealthReport
alias Dowser.Elasticsearch.Info
alias Dowser.Elasticsearch.XPack

Info.info!()["version"]["number"]
# "9.0.0"
HealthReport.health_report!(feature: "disk")["indicators"]
XPack.usage!()["watcher"]["count"]

Streaming

A search returns one page. Dowser.Elasticsearch.Streamer walks all of them, as a lazy Stream of hits:

alias Dowser.Elasticsearch.Streamer

%{query: %{match_all: %{}}, size: 1_000}
|> Streamer.stream(index: "posts")
|> Stream.map(& &1["_source"])
|> Enum.each(&process/1)

Each element is a hit, cast exactly as Search.search/2 would cast it. Under the hood a point in time pins the index against concurrent writes and search_after pages through it, with _shard_doc appended to your sort as the tiebreaker that makes the paging deterministic. The point in time is opened when enumeration starts and closed when it ends — whether that is the last page, an Enum.take/2, or an exception.

A query with no size gets 1000 rather than Elasticsearch's default of 10: at ten hits per round trip, a million documents is a hundred thousand requests.

In parallel

search_after is sequential by construction — a page's cursor is the last hit of the page before it — so one stream cannot fetch pages in parallel. Slicing is what does: stream_slices/4 splits one point in time into disjoint subsets and walks them at once.

%{query: %{match_all: %{}}, size: 1_000}
|> Streamer.stream_slices(4, &Enum.count/1, index: "posts")
|> Enum.sum()

Note that it takes a function, not a stream. Each slice is consumed inside the task that reads it; a lazy stream handed back out of a task would be built there and then run every page in the caller — concurrent in name only. So keep what the function returns small: a count, a sum, :ok.

Elasticsearch divides first across shards, then within each shard, so the useful number of slices is your shard count. To spread a walk across nodes instead of processes, open a point in time yourself and give each node one slice — slice is an ordinary search body field:

Streamer.stream(%{query: ..., slice: %{id: 2, max: 8}}, pit: pit_id)

There is no stream!/2: a stream has nothing to unwrap, and raises on enumeration anyway.

Migrations

An application changes its indices with migrations, as it changes its database tables with Ecto's: mix dowser.gen.migration, mix dowser.migrate, mix dowser.rollback, from dowser_client. Every use Dowser.Elasticsearch.Repo runs them against Elasticsearch through Dowser.Elasticsearch.Migration.Adapter, so there is nothing to configure but the repos to migrate:

# config/config.exs
config :my_app, dowser_repos: [MyApp.Search]

# .formatter.exs — the migration DSL without parentheses
[import_deps: [:dowser_elasticsearch], ...]
# priv/dowser/migrations/20261104120000_create_events.exs
defmodule MyApp.Search.Migrations.CreateEvents do
  use Dowser.Migration

  def change do
    create_index "events", %{
      settings: %{number_of_shards: 1},
      mappings: %{dynamic: :strict, properties: %{title: %{type: :text}}}
    }
  end
end
mix dowser.migrate --dry-run --reindex   # the requests it would send
mix dowser.migrate --reindex             # copies the indices it must
mix dowser.rollback

"events" is an alias of events_20261104120000 — events-20261104120000 with create_index "events", body, separator: "-". The migrations run are recorded in the dowser_schema_migrations index, which also holds the lock keeping two runs apart. See Dowser.Migration and Dowser.Migrator.

Repository pattern

Calling Search, Document, and Index directly means repeating index: "posts" (or recomputing it) on every call. use Dowser.Elasticsearch.Repository generates index-bound versions of those functions instead:

defmodule MyApp.Posts do
  use Dowser.Elasticsearch.Repository,
    index: "posts",
    only: [
      search: [:search, :count],
      document: [:index, :get, :delete, :exists],
      index: [:create_index, :refresh]
    ]
end

MyApp.Posts.create_index!(%{mappings: %{properties: %{title: %{type: "text"}}}})
# PUT /posts

{:ok, %{"_id" => id}} = MyApp.Posts.index_doc(%{title: "hello"})
# POST /posts/_doc

MyApp.Posts.search!(%{query: %{match_all: %{}}})
# GET /posts/_search

MyApp.Posts.doc_exists?(id)
# HEAD /posts/_doc/:id — true/false

A handful of Document functions are generated under a different name to keep them distinct from other modules' functions (index → index_doc, exists/exists? → doc_exists/doc_exists?, create → create_doc, delete → delete_doc, get → get_doc, update → update_doc) — see Dowser.Elasticsearch.Repository for the full rename table.

:index also accepts a 1-arity function for a dynamic (e.g. per-tenant or time-based) index. The generated functions then take a term — positionally or via the :index option — and resolve it through that function:

defmodule MyApp.TenantLogs do
  use Dowser.Elasticsearch.Repository,
    index: &__MODULE__.index_name/1,
    only: [search: [:search], document: [:index]]

  def index_name(tenant), do: "logs_#{tenant}"
end

MyApp.TenantLogs.index_doc(%{message: "boom"}, "acme")
# POST /logs_acme/_doc

MyApp.TenantLogs.search(%{query: %{match_all: %{}}}, index: "acme")
# GET /logs_acme/_search

Without :only/:except, every index-related function of Search, Document, and Index is generated. Functions that don't target an index (templates, reindex, scroll, …) are never generated — call their module directly.

Type casting

By default, response bodies come back as plain decoded JSON — dates, IPs and other Elasticsearch types stay strings. Wiring Dowser.Elasticsearch.Codec into the repo — as both passes — casts them automatically, per index mapping, on every call to Search and Document:

config :my_app, MyApp.Search,
  endpoint: "http://localhost:9200",
  decoder: Dowser.Elasticsearch.Codec,
  encoder: Dowser.Elasticsearch.Codec
# with "published_at" mapped {"type": "date", "format": "strict_date_time_no_millis"}
Document.get!("posts", "1")
# %{"_source" => %{"published_at" => ~U[2026-08-11 00:00:00Z]}, ...}

Document.index!(%{published_at: ~U[2026-08-11 00:00:00Z]}, "posts")
# POST /posts/_doc {"published_at":"2026-08-11T00:00:00Z"}

decode/2 finds documents anywhere in a response envelope and casts each one against the mapping of its own _index. encode/2 is the mirror image, but only ever runs on a document source — the functions in Document name the index each source is going to and where in the request body it sits. A query is never cast, since a query value has no mapping entry to anchor it: build queries in the shape Elasticsearch expects.

Mappings are fetched once and cached by Dowser.Elasticsearch.MappingCacher. This package doesn't start it: add it to your supervision tree, next to the repo —

children = [MyApp.Search, Dowser.Elasticsearch.MappingCacher]

— or give the mappings yourself, and skip the cluster entirely: config :dowser_elasticsearch, mapping: MyApp.Mappings (a Dowser.Mapping module), or struct: on a call (a document module's own mapping). Casting against the cluster's mappings with no cacher started fails with a Dowser.MappingError naming it, rather than leaving every value uncast.

A flattened field, and an object mapped "enabled": false, are returned exactly as stored.

What is cast

The casting is dowser_client's (Dowser.Type), the same on Elasticsearch and OpenSearch. Only the types JSON can't represent are cast; every other value passes through as decoded JSON:

mapping type stored Elixir
date, date_nanos depends on the format — see below Date, Time, DateTime, NaiveDateTime
date_range (strict_date) {"gte": "2019-03-01", "lte": "2019-03-31"} Date.Range
integer_range, long_range {"gte": 1, "lte": 10} Range
ip "127.0.0.1" {127, 0, 0, 1}
binary Base64 raw binary
geo_point {"lat": 1.2, "lon": 3.4} {1.2, 3.4}

Dates

A date field's format decides what it is cast to. Nine formats are, each read by the Elixir function that parses it:

format stored Elixir
epoch_millis 1553391286000 DateTime.from_unix(value, :millisecond)
epoch_second 1553391286 DateTime.from_unix(value, :second)
strict_date "2019-03-23" Date.from_iso8601/1
strict_date_hour_minute_second "2019-03-23T21:34:46" NaiveDateTime.from_iso8601/1
strict_date_hour_minute_second_millis "2019-03-23T21:34:46.123" NaiveDateTime.from_iso8601/1
strict_date_time_no_millis "2019-03-23T21:34:46-04:00" DateTime.from_iso8601/1
strict_date_time "2019-03-23T21:34:46.123-04:00" DateTime.from_iso8601/1
strict_hour_minute_second "21:34:46" Time.from_iso8601/1
strict_hour_minute_second_millis "21:34:46.123" Time.from_iso8601/1

Ranges

A range is cast only when the Elixir struct holds it exactly: a date_range mapped strict_date with gte/lte dates, an integer_range or long_range with integer gte/lte bounds, gte no greater than lte. Anything else — an open-ended range, gt/lt bounds, another date format — comes back as stored. Only a range stepping by 1 is written back as a range.

Casting a type of your own

Individual values go through a Dowser.Type module — Dowser.Type itself by default. Covering one more mapping type is a clause per direction and a delegation back to it for the rest:

defmodule MyApp.Type do
  @behaviour Dowser.Type

  @impl true
  def load(value, %{"type" => "scaled_float", "scaling_factor" => factor}) do
    value / factor
  end

  def load(value, field), do: Dowser.Type.load(value, field)

  @impl true
  def dump(value, %{"type" => "scaled_float", "scaling_factor" => factor}) do
    round(value * factor)
  end

  def dump(value, field), do: Dowser.Type.dump(value, field)
end

Delegating last inherits the built-in casts, the nil short-circuit and the fall-through to identity; a clause matching a type Dowser.Type already handles replaces that cast.

Point the casting at it with :types — globally, per repo, or per request, most specific first:

# globally
config :dowser_elasticsearch, types: MyApp.Type

# per repo, alongside the pass it belongs to
decoder: {Dowser.Elasticsearch.Codec, types: MyApp.Type}

# per request, on any API function
Document.get("posts", "1", types: MyApp.Type)

The casting options

:types is one of five options deciding how documents are cast, all set the same three ways:

Option Decides Default
:mapping where the mapping comes from: a map, a Dowser.Mapping module, or a document module config :dowser_elasticsearch, mapping:, else the cluster's via Dowser.Elasticsearch.MappingCacher, which you start
:struct load each document into a document module's struct (Dowser.Schema), and dump it back — per call only maps
:types the Dowser.Type module leaf values go through Dowser.Type
:unknown_fields :keep, or :raise a Dowser.UnknownFieldError for a field the mapping doesn't declare :keep
:mapping_failure :error, :warn or :ignore when the mapping can't be fetched :error
Document.get("posts", "1", struct: MyApp.Post)          # {:ok, %{"_source" => %MyApp.Post{}}}
Document.get("posts", "1", mapping: MyApp.Post)         # cast with its mapping, still a map
Search.search(query, index: "posts", unknown_fields: :raise)

:mapping and :struct turn casting on for that call even on a repo with no :decoder/:encoder configured.

Testing

A repo is where a test takes over: every request goes through it, and nothing else needs replacing.

Stub the HTTP layer, keep the pipeline

Dowser.Client.HTTP.Stub answers every request the test process makes, while everything else — the repo, URL building, JSON, casting — runs as in production. Point the calls at an ad-hoc repo (or your own, configured in config/test.exs); stubs are per process, so tests stay async: true:

test "loads a post" do
  Dowser.Client.HTTP.Stub.stub(fn :get, "http://localhost:9200/posts/_doc/1", _headers, _body, _opts ->
    Dowser.Client.HTTP.Stub.json(200, %{
      "_index" => "posts",
      "_id" => "1",
      "_source" => %{"title" => "hi", "published_at" => "2026-08-11T00:00:00.000Z"}
    })
  end)

  assert {:ok, %{"_source" => %MyApp.Post{title: "hi"}}} =
           Dowser.Elasticsearch.Document.get("posts", "1",
             repo: [endpoint: "http://localhost:9200"],
             struct: MyApp.Post
           )
end

Casting against the cluster's mappings needs mappings in tests too: give them with config :dowser_elasticsearch, mappings: %{"posts" => %{...}} (no cacher needed), mapping: / struct:, or start Dowser.Elasticsearch.MappingCacher with start_supervised!/1 and put/3 the mappings into it.

Mock the repo

For a unit test that only needs a canned answer, mock the repo itself: a repo is the Dowser.Repo behaviour, so Mox can stand in for one.

# test/test_helper.exs
Mox.defmock(MyApp.SearchMock, for: Dowser.Repo)

# in your test
Mox.expect(MyApp.SearchMock, :request, fn :post, "/posts/_search", _query, _opts ->
  {:ok, %Dowser.Client.Response{status: 200, headers: %{}, body: %{"hits" => %{"hits" => []}}}}
end)

assert {:ok, %{"hits" => %{"hits" => []}}} =
         Dowser.Elasticsearch.Search.search(%{query: %{match_all: %{}}}, index: "posts", repo: MyApp.SearchMock)

The mock stands in for the whole transport — encoding, decoding and casting included — so its body is exactly what the caller gets. Set config :dowser_elasticsearch, default_repo: MyApp.SearchMock in config/test.exs to have every call that names no repo go to it.

Mock the repo with Mimic

Mimic stubs the functions of an existing module, per test process. A test-only repo module becomes the default repo in the test environment, so the code under test — which names no repo — talks to it, and each test says what request/4 answers. request/4 is the one function every API call goes through.

# mix.exs
defp deps do
  [
    # ...
    {:mimic, "~> 1.10", only: :test}
  ]
end

defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_env), do: ["lib"]
# test/support/search_mock.ex — a repo like any other, compiled for tests only
defmodule MyApp.SearchMock do
  use Dowser.Elasticsearch.Repo, otp_app: :my_app
end
# config/test.exs — the default repo is the mock in tests
config :my_app, MyApp.SearchMock, endpoint: "http://search.test"
config :dowser_elasticsearch, default_repo: MyApp.SearchMock
# test/test_helper.exs
Mimic.copy(MyApp.SearchMock)
ExUnit.start()
defmodule MyApp.PostsTest do
  use ExUnit.Case, async: true
  use Mimic

  test "searches the posts" do
    expect(MyApp.SearchMock, :request, fn :post, "/posts/_search", _query, _opts ->
      {:ok, %Dowser.Client.Response{status: 200, headers: %{}, body: %{"hits" => %{"hits" => []}}}}
    end)

    # MyApp.Posts.search/1 calls Dowser.Elasticsearch.Search.search(query, index: "posts"),
    # with no :repo — so it goes to the default repo, the mock.
    assert {:ok, %{"hits" => %{"hits" => []}}} = MyApp.Posts.search("hello")
  end
end

A few things to know:

Against a real cluster

Point a repo at a local cluster in config/test.exs, and start the mapping cacher if the tests cast against the cluster's mappings. Tests that share an index can't run concurrently: run them async: false, or give each test indices of its own ("posts_#{System.unique_integer([:positive])}"), created in setup and deleted in on_exit.

Compatibility

Elasticsearch version

Tested against Elasticsearch 9.x. Earlier versions haven't been tested but should work, since the wrapped endpoints are stable across releases.

Endpoint coverage

Elasticsearch groups its API into tags, and each supported tag maps to one Dowser.Elasticsearch.* module. Every endpoint of a supported tag is implemented, each as a pair of functions (search/2 and search!/2).

Endpoint tag Supported Module
Behavioral analytics ❌
Compact and aligned text (CAT) ✅ Cat
Cluster ✅ Cluster
Cluster - Health ✅ HealthReport
Connector ❌
Cross-cluster replication ❌
Data stream ❌
Document ✅ Document
Enrich ❌
EQL ❌
ES|QL ❌
Features ❌
Fleet ❌
Graph explore ❌
Index ✅ Index
Index lifecycle management ❌
Inference ❌
Info ✅ Info
Ingest ❌
Licensing ❌
Logstash ❌
Machine learning ❌
Machine learning anomaly detection ❌
Machine learning data frame analytics ❌
Machine learning trained model ❌
Migration ❌
Query rules ❌
Reindex ✅ Reindex
Rollup ❌
Script ❌
Search ✅ Search
Search application ❌
Searchable snapshots ❌
Security ❌
Snapshot and restore ❌
Snapshot lifecycle management ❌
SQL ❌
Streams ❌
Synonyms ❌
Task management ❌
Text structure ❌
Transform ❌
Usage ✅ XPack
Watcher ❌

Development

mix deps.get
mix test
mix lint    # format --check-formatted, credo --strict, dialyzer

This package is developed alongside dowser_client. To build against a working copy of it rather than the published release — needed whenever this package relies on a dowser_client change not on Hex yet — set DOWSER_CLIENT_PATH:

DOWSER_CLIENT_PATH=../dowser_client mix deps.get
DOWSER_CLIENT_PATH=../dowser_client mix test

mix lint runs the three checks this project keeps green. Dialyzer's PLTs are built into priv/plts/ on the first run, which takes a couple of minutes; the runs after that are seconds.

Trademark Notice

This project is an independent, community-maintained library and is not affiliated with, endorsed by, or sponsored by Elasticsearch B.V.

Elasticsearch is a trademark of Elasticsearch B.V., registered in the U.S. and in other countries.