Most search migrations don’t fail because Elasticsearch or Solr can’t do the job. They fail because on Monday morning someone searches for “annual leave policy”, gets a different first result than they did on Friday, and files a ticket. Multiply that by a few thousand users and the migration gets rolled back, often badly.
This playbook covers a Solr to Elasticsearch migration (and the reverse) done without a maintenance window: search stays up, results stay trustworthy, and there is a rollback path at every step.
Why teams switch engines (in either direction)
Both engines are built on Apache Lucene and score with BM25 by default, so the reasons to move are usually organisational rather than algorithmic:
- Towards Elasticsearch: the team already runs the Elastic Stack, wants index lifecycle management (ILM) and data streams, or wants a managed offering.
- Towards Solr: a preference for an Apache 2.0 project under the Apache Software Foundation, or heavy use of Solr-specific features such as streaming expressions and the JSON Facet API.
- Licensing has changed more than once. Elastic moved Elasticsearch from Apache 2.0 to a dual SSPL/Elastic License v2 model in 2021, which led AWS to fork it as OpenSearch. In August 2024 Elastic added AGPL as a third license option alongside SSPL and ELv2. Check current terms with your legal team.
Versions matter for planning. Solr 9.0 shipped in May 2022 and removed the old CDCR replication, the autoscaling framework and the bundled Data Import Handler. Solr 10.0 followed in March 2026 with Java 21 as the minimum. On the Elastic side, 9.0 arrived in 2025 on Lucene 10. If your source cluster is Solr 8.x or Elasticsearch 7.x, you are really doing two migrations at once: an engine change and a major-version jump. Plan for both.
How a zero-downtime search migration actually works
The core idea: never make the old search cluster your source of truth. The data lives in a database, CMS or object store; both clusters are just projections of it. A zero-downtime migration means feeding a second projection, proving it behaves the same, and moving traffic gradually.

The flow has seven stages:
- Source of truth. The primary database or content store. Every document in either engine must be rebuildable from here. If some fields exist only in the index, fix that first.
- Change capture. Either change data capture (CDC) from the database log, for example Debezium streaming row changes into Kafka, or a dual-write in the application’s indexing service. CDC is usually safer because it can’t be forgotten by a code path that bypasses the indexer.
- Two indexers, one stream. Separate consumers write to Solr and Elasticsearch, each with its own offset, retries and dead-letter queue, so a mapping bug in the new cluster never blocks the old one.
- Backfill. A bulk job reads a consistent snapshot from the source of truth and loads it into the new cluster using
_bulk(Elasticsearch) or batched/updaterequests (Solr). Replay the change stream from the snapshot position so nothing falls through the gap. - Shadow reads. The search API keeps answering users from the old cluster but copies a sample of real queries, asynchronously, to the new one. Responses are compared and logged, never shown.
- Traffic shifting. Once parity metrics pass, a feature flag or router sends 1%, then 5%, 25%, 50% and 100% of live reads to the new engine, with automatic fallback on errors.
- Cutover and rollback window. Reads move fully, but the old cluster keeps receiving writes for an agreed period, so rolling back is a flag flip, not a reindex.
Note that Elasticsearch’s _reindex API isn’t in that list: reindex-from-remote only reads from Elasticsearch clusters. It becomes useful later, for rebuilding an index behind an alias.
Why aliases are the most important detail
Never let clients query a physical index or collection name. In Elasticsearch, point clients at an alias and swap it atomically with a single _aliases request containing a remove and an add action. The Elastic alias documentation notes that the alias never points to both targets during the swap. In Solr, CREATEALIAS on an existing alias replaces it, and the Solr alias management guide explicitly describes aliases as a way to atomically swap which collection is live.
POST _aliases
{
"actions": [
{ "remove": { "index": "kb-docs-v1", "alias": "kb-docs" } },
{ "add": { "index": "kb-docs-v2", "alias": "kb-docs" } }
]
}Your first Elasticsearch index will almost never be your last. With an alias in place, fixing an analyzer mistake is a rebuild and a swap, not a client release.
Solr to Elasticsearch concept mapping
Most migration effort goes into translating concepts, not moving data. “Close” below means the feature exists on both sides but behaves differently enough to test.
| Solr | Elasticsearch | Notes |
|---|---|---|
| Collection (SolrCloud) | Index (behind an alias) | Primary shard count is fixed at index creation in Elasticsearch; changing it means split/shrink or a reindex. |
| Configset + managed-schema | Index template + mappings + settings | Use component templates so analyzers and mappings are versioned in Git. |
uniqueKey | _id | Keep the same ID so reconciliation and dual-writes are idempotent. |
dynamicField (e.g. *_s) | Dynamic templates | Match on field name patterns with match/path_match to keep existing suffix conventions. |
copyField | copy_to | Close. copy_to doesn’t alter _source; neither chains copies. |
| Analyzer chain (tokenizer + filters) in the field type | Custom analyzer in index settings | Most Lucene filters exist on both sides, sometimes under different names. Compare token output, not config. |
| Synonym files / SynonymGraphFilter | synonym_graph filter (optionally the synonyms API) | Apply at query time where possible so synonym changes don’t force a reindex. |
eDisMax (qf, pf, mm, tie) | bool + multi_match (best_fields, tie_breaker) + phrase clauses in should | Close. mm maps to minimum_should_match; edge cases differ. |
fq filter queries | bool.filter | Both are cached and don’t affect score. |
bf / boost function queries | function_score or script_score | eDisMax boost is multiplicative, bf is additive: pick boost_mode to match. |
| Facets / JSON Facet API | Aggregations | Terms, range and nested facets translate well; check counts on multi-valued fields. |
| Nested child documents | nested type or join field | Needs query rewrites, not just mapping changes. |
Soft commit / autoSoftCommit | refresh_interval | This controls index freshness. Set it deliberately during backfill (often disabled, then restored). |
cursorMark deep paging | search_after with a point-in-time (PIT) | Use these for exports and reconciliation, not for user paging. |
DenseVectorField + knn parser | dense_vector + kNN search | Re-embed only if the model changes; vectors themselves can be copied. |
| Time routed aliases | Data streams + ILM | ILM has no direct Solr equivalent. |
A worked example: translating an eDisMax handler
Take a hypothetical internal knowledge-base search on SolrCloud. The source of truth is a PostgreSQL database; the request handler looks like this:
defType=edismax
qf=title^4 body^1 tags^2
pf=title^8
mm=2<75%
tie=0.1
fq=status:published
boost=recip(ms(NOW,updated_at),3.16e-11,1,1)A faithful first-pass Elasticsearch translation:
{
"query": {
"function_score": {
"query": {
"bool": {
"must": {
"multi_match": {
"query": "annual leave policy",
"fields": ["title^4", "body", "tags^2"],
"type": "best_fields",
"tie_breaker": 0.1,
"minimum_should_match": "2<75%"
}
},
"should": {
"match_phrase": { "title": { "query": "annual leave policy", "boost": 8 } }
},
"filter": { "term": { "status": "published" } }
}
},
"functions": [
{ "gauss": { "updated_at": { "origin": "now", "scale": "365d", "decay": 0.5 } } }
],
"boost_mode": "multiply"
}
}
}Two things to be honest about. First, the recency decay is not mathematically identical: Solr’s recip and Elasticsearch’s gauss curves have different shapes. You can reproduce recip exactly with script_score, but ask whether you actually need to. Second, even with identical formulas, scores will differ if the analyzers differ, or if term statistics are computed per shard differently. That is why the next section exists.
The phased playbook
Phase 0: Inventory and freeze the contract
- Export every request handler, query parser and parameter your clients actually send. Search logs beat config files, because config shows what is possible and logs show what is used.
- Pull the top queries by volume, plus a random sample of the long tail. These become your test set.
- List every field that isn’t rebuildable from the source of truth. Fix those first.
- Define an engine-neutral search API response contract.
Phase 1: Build the target schema
- Translate field types and analyzers. Compare them by running the same strings through Solr’s analysis screen or field analysis API and Elasticsearch’s
_analyzeAPI, and diff the tokens. - Put mappings, settings and templates in version control. Disable dynamic mapping for production indices, or restrict it to named templates.
Phase 2: Stand up the change stream and backfill
- Start CDC or dual-write into the new cluster before the backfill begins, so updates made during the backfill aren’t lost.
- Make writes idempotent: same document ID, and ideally external versioning (for example the database’s update timestamp or log sequence number) so an older event never overwrites a newer one.
- Backfill with
_bulk, replicas at 0 and refresh disabled; restore both afterwards and wait for green health.
Phase 3: Reconcile
- Compare document counts per collection/index and per important filter value (tenant, status, content type).
- Sample document IDs and compare field-level hashes. Count matches alone hide truncated fields and analyzer surprises.
Phase 4: Shadow reads and relevance parity
Mirror production queries asynchronously and compare results (details below). Iterate on analyzers and query templates until the parity gates pass.
Phase 5: Canary and ramp
Route a small, sticky percentage of users so nobody flips between engines mid-session. Ramp only when each step holds steady through a peak-traffic period.
Phase 6: Cutover, soak, decommission
Move 100% of reads. Keep writing to the old cluster through the rollback window. Decommission only after the window ends and the parity dashboards have been quiet.
Relevance parity testing
“Same results” is the wrong target. Two Lucene engines with different analyzers and shard layouts will almost never return identical ranked lists, and you don’t want to spend three months chasing byte-for-byte equality. The target is no user-visible regression, measured against judgements.
- Build a judgement list. Take a few hundred representative queries and grade the relevant documents (graded 0 to 3 works well). Click logs can bootstrap this, but have domain experts review the top queries.
- Score both engines against the same judgements. Elasticsearch’s ranking evaluation API supports DCG (with a
normalizeoption for nDCG), mean reciprocal rank, precision, recall and ERR. For Solr, compute the same metrics offline from the result lists so both sides use identical maths. - Diff the overlap. Low top-10 overlap isn’t automatically bad; low overlap plus lower nDCG is.
- Triage by query class. Regressions cluster: part numbers and codes (tokenizer differences), plurals (stemmer choice), synonyms, and very short queries (
mmbehaviour).
If you want the new cluster to be better rather than merely equal, finish the migration at parity first and tune afterwards. Mixing the two makes regressions impossible to attribute. Once you are stable, AI-driven relevance tuning becomes a separate, measurable project.
How we measure success
Agree these gates with the product owner before any traffic moves. There are no universal target numbers: measure the old cluster first and set gates relative to that baseline.
| Metric | What it tells you | How to measure | Gate |
|---|---|---|---|
| nDCG@10 / MRR | Ranking quality against judgements | Judgement list, both engines, same maths | New ≥ baseline within an agreed tolerance, per query class |
| Zero-result rate | Analyzer or mm regressions | Shadow-read logs | No increase versus baseline |
| Top-k overlap | Where to look for problems | Shadow-read diff per query | Diagnostic only; investigate outliers |
| p95 / p99 latency | User-facing performance | Measured at the search API, not the engine | ≤ baseline at peak load |
| Error / timeout rate | Stability | API and cluster metrics | ≤ baseline; any spike stops the ramp |
| Index freshness lag | Change-stream health | Source commit time vs. searchable time; consumer lag | Within the product’s freshness SLO |
| Document count and hash reconciliation | Completeness and correctness | Scheduled job over counts and sampled field hashes | Zero unexplained drift |
| Behavioural signals | Real-world acceptance | CTR, reformulation rate, “no useful result” feedback per canary cohort | No statistically meaningful drop |
Big-bang vs. shadow migration vs. partner-led migration
| Approach | Downtime risk | Rollback | Effort | Best for |
|---|---|---|---|---|
| Big-bang (reindex, switch overnight) | High: any mismatch is found by users | Slow; often needs a reindex back | Low up front, high if it goes wrong | Small, non-critical indices with tolerant users |
| Dual-write/CDC with shadow reads (in-house) | Low | Flag flip during the rollback window | Higher: pipelines, comparison tooling, dashboards | Teams with search and data engineering capacity |
| Same pattern, delivered with an engineering partner such as Exuverse | Low | Same as above | Shared; your team keeps ownership of the source of truth and judgements | Teams that need pipeline, cloud and search skills they don’t have in-house |
Exuverse works across data pipelines and API integration, cloud infrastructure and DevOps, and search-backed AI systems, the three skill sets this pattern needs. A partner changes who does the work, not the method.
Not every search problem needs a new engine. If your cluster is slow, unstable or returning poor results, a tuning engagement is often cheaper than a migration; our guides to Elasticsearch consulting and Apache Solr consulting services cover when that is the better call.
Pros and cons of the dual-write/shadow approach
Pros
- No maintenance window, and users never become the test suite.
- Rollback is cheap for as long as both clusters receive writes.
- Parity is proven with metrics, which makes sign-off with product owners much easier.
- The CDC pipeline and aliases are reused for every future reindex and upgrade.
Cons
- You pay for two clusters during the overlap period.
- More moving parts: Kafka (or similar), two consumers, comparison jobs and dashboards.
- Judgement lists take real people’s time to build.
- With an external partner, knowledge transfer must be planned explicitly.
Going the other way: Elasticsearch to Solr
The architecture is identical; the translation traps are different.
_sourcevs. stored fields. Elasticsearch keeps the original JSON by default. In Solr, fields are only retrievable if they are stored or have docValues. Decide per field, or you’ll discover missing display fields in shadow testing.- Nested objects. Elasticsearch
objectfields flatten into dotted names;nesteddocuments become Solr child documents with block-join queries. Plan the query rewrites early. - Ingest pipelines and Painless scripts become update request processors, function queries, or code in your indexer. Moving logic upstream is usually the cleaner option.
- ILM and data streams have no one-to-one equivalent. Time routed aliases cover some cases; for the rest, lifecycle becomes an operational job.
- Cross-data-centre replication. Solr’s old CDCR was removed in 9.0. Current Solr offers a Kafka-based CrossDC module, which fits neatly beside a Kafka-based migration pipeline.
- Security moves to Solr’s authentication and rule-based authorisation plugins; it is rarely a straight copy.
Common mistakes
- Starting the backfill before the change stream. Updates made during the backfill vanish.
- Non-idempotent writes. Retries and replays create duplicates or let stale events overwrite fresh ones.
- Chasing identical scores. Aim for judged parity, not equal floating-point numbers.
- Shadow reads on the hot path. Mirroring must be asynchronous and sampled, or you double your latency.
- Turning off the old writer on cutover day. That throws away your rollback.
- Forgetting non-search consumers. Autocomplete, sitemaps, analytics exports and RAG retrievers often query the index directly. If you run retrieval-augmented generation on Solr or Elasticsearch, the retriever needs its own parity check.
A retriever that quietly returns different chunks degrades LLM answers without raising a single error, so evaluate retrieval for RAG pipelines with the same judgement lists you use for the search UI.
Frequently asked questions
Can I use the Elasticsearch reindex API to migrate from Solr?
No. Reindex from a remote cluster reads from another Elasticsearch cluster, not from Solr. For a Solr to Elasticsearch migration, backfill from your source of truth using the _bulk API, and use _reindex later for rebuilds inside Elasticsearch.
How long does a zero-downtime Solr to Elasticsearch migration take?
It depends far more on schema complexity, query patterns and how quickly judgements can be collected than on data volume. Backfill is usually quick; parity testing and the ramp take the calendar time.
Do I need Kafka or Debezium?
Not strictly. A dual-write in a single indexing service works if every write path goes through it. CDC from the database log is safer when multiple applications write data, because it captures changes no matter which code path made them.
Will relevance scores be identical after migration?
Rarely. Both use Lucene and BM25 by default, but analyzers, shard-level term statistics and function formulas differ. Measure judged quality (nDCG, MRR, zero-result rate) instead of comparing raw scores.
How do I roll back if something goes wrong after cutover?
Keep writing to the old cluster during an agreed rollback window and route reads through a feature flag or router. Rolling back is then a routing change. After the window closes, rollback means a rebuild, so don’t close it early.
Should I consider OpenSearch instead?
OpenSearch is a 2021 fork of Elasticsearch 7.10 and shares most of its query DSL, so this playbook applies. APIs have diverged since, so verify newer features against OpenSearch’s own documentation.
What about vector and hybrid search fields?
Both engines support dense vectors with approximate kNN. If the embedding model stays the same, you can copy vectors across. If you change models during the migration, you are changing relevance as well, so treat it as a separate project.
Planning a search engine switch?
If you are weighing a Solr to Elasticsearch migration, or the reverse, start by listing every field and query pattern you depend on. That inventory decides most of the plan. If you’d like engineers who work with Solr, Elasticsearch and the data pipelines around them to review your plan, or to build the CDC, shadow-read and parity tooling with your team, get in touch with Exuverse. For teams also rethinking what internal search should do once it is on a stable platform, our write-up on building an AI-powered internal search system is a useful next read.