Overview
This page aims to help current users of Elasticsearch make the transition to Meilisearch. For a high-level comparison of the two search engines, see Meilisearch vs Elasticsearch.
Overview
This guide walks you through exporting documents from an Elasticsearch index and importing them into Meilisearch using a script in JavaScript, Python, or Ruby. You can also skip directly to the finished script. The migration process consists of four steps:
- Export your data from Elasticsearch
- Prepare your data for Meilisearch
- Import your data into Meilisearch
- Configure your Meilisearch index settings (optional)
To help with the transition, this guide also includes a comparison of settings and parameters, query types, and API methods. Before continuing, make sure you have Meilisearch installed and have access to a command-line terminal. If you’re unsure how to install Meilisearch, see our quick start.
This guide includes examples in JavaScript, Python, and Ruby. The packages used:
- JavaScript:
@elastic/elasticsearch8.x,meilisearch(compatible with Meilisearch v1.0+) - Python:
elasticsearch8.x,meilisearch - Ruby:
elasticsearch8.x,meilisearch
Export your Elasticsearch data
Initialize project
JavaScript
Python
Ruby
mkdir elastic-meilisearch-migration
cd elastic-meilisearch-migration
npm init -y
touch script.js
mkdir elastic-meilisearch-migration
cd elastic-meilisearch-migration
touch script.py
mkdir elastic-meilisearch-migration
cd elastic-meilisearch-migration
touch script.rb
Install dependencies
JavaScript
Python
Ruby
npm install -s @elastic/elasticsearch meilisearch
pip install elasticsearch meilisearch
gem install elasticsearch meilisearch
Create Elasticsearch client
You need your Elasticsearch host URL and authentication credentials. Paste the below code in your script:
JavaScript
Python
Ruby
const { Client } = require("@elastic/elasticsearch");
const esClient = new Client({
node: "ELASTICSEARCH_URL",
auth: {
// Use API key authentication:
apiKey: "ELASTICSEARCH_API_KEY",
// Or use basic authentication:
// username: "USERNAME",
// password: "PASSWORD",
},
});
from elasticsearch import Elasticsearch
es_client = Elasticsearch(
"ELASTICSEARCH_URL",
# Use API key authentication:
api_key="ELASTICSEARCH_API_KEY",
# Or use basic authentication:
# basic_auth=("USERNAME", "PASSWORD"),
)
require 'elasticsearch'
es_client = Elasticsearch::Client.new(
url: 'ELASTICSEARCH_URL',
# Use API key authentication:
api_key: 'ELASTICSEARCH_API_KEY'
# Or use basic authentication:
# user: 'USERNAME',
# password: 'PASSWORD'
)
Replace ELASTICSEARCH_URL with your Elasticsearch cluster URL (for example, https://localhost:9200) and provide your authentication credentials.
Fetch data from Elasticsearch
Use the Point in Time API with search_after to paginate through all documents in the index. This approach is recommended over the deprecated Scroll API.
JavaScript
Python
Ruby
const INDEX_NAME = "YOUR_INDEX_NAME";
const BATCH_SIZE = 10000;
async function fetchAllDocuments() {
const records = [];
// Open a Point in Time
const pit = await esClient.openPointInTime({
index: INDEX_NAME,
keep_alive: "5m",
});
let searchAfter;
while (true) {
const response = await esClient.search({
body: {
size: BATCH_SIZE,
query: { match_all: {} },
pit: { id: pit.id, keep_alive: "5m" },
sort: [{ _doc: "asc" }],
...(searchAfter && { search_after: searchAfter }),
},
});
const hits = response.hits.hits;
if (hits.length === 0) break;
records.push(...hits);
searchAfter = hits[hits.length - 1].sort;
}
// Close the Point in Time
await esClient.closePointInTime({ id: pit.id });
return records;
}
INDEX_NAME = "YOUR_INDEX_NAME"
BATCH_SIZE = 10000
def fetch_all_documents():
records = []
# Open a Point in Time
pit = es_client.open_point_in_time(
index=INDEX_NAME,
keep_alive="5m"
)
search_after = None
while True:
body = {
"size": BATCH_SIZE,
"query": {"match_all": {}},
"pit": {"id": pit["id"], "keep_alive": "5m"},
"sort": [{"_doc": "asc"}],
}
if search_after:
body["search_after"] = search_after
response = es_client.search(body=body)
hits = response["hits"]["hits"]
if not hits:
break
records.extend(hits)
search_after = hits[-1]["sort"]
# Close the Point in Time
es_client.close_point_in_time(id=pit["id"])
return records
INDEX_NAME = 'YOUR_INDEX_NAME'
BATCH_SIZE = 10_000
def fetch_all_documents(es_client)
records = []
# Open a Point in Time
pit = es_client.open_point_in_time(
index: INDEX_NAME,
keep_alive: '5m'
)
search_after = nil
loop do
body = {
size: BATCH_SIZE,
query: { match_all: {} },
pit: { id: pit['id'], keep_alive: '5m' },
sort: [{ _doc: 'asc' }]
}
body[:search_after] = search_after if search_after
response = es_client.search(body: body)
hits = response['hits']['hits']
break if hits.empty?
records.concat(hits)
search_after = hits.last['sort']
end
# Close the Point in Time
es_client.close_point_in_time(body: { id: pit['id'] })
records
end
Replace YOUR_INDEX_NAME with the name of the Elasticsearch index you want to migrate.
Prepare your data
Elasticsearch documents are wrapped in metadata (_id, _index, _source). You need to extract the document data from _source and ensure each document has a valid primary key for Meilisearch.
JavaScript
Python
Ruby
function prepareDocuments(hits) {
return hits.map((hit) => {
const doc = hit._source;
doc.id = hit._id;
return doc;
});
}
def prepare_documents(hits):
documents = []
for hit in hits:
doc = hit["_source"]
doc["id"] = hit["_id"]
documents.append(doc)
return documents
def prepare_documents(hits)
hits.map do |hit|
doc = hit['_source']
doc['id'] = hit['_id']
doc
end
end
Meilisearch stores documents as flat JSON objects. If your Elasticsearch documents use nested objects or the nested mapping type, you must flatten them before indexing. For example, { “author”: { “name”: “John” } } should become { “author_name”: “John” } or kept as-is if you only need it for display purposes. Only top-level fields can be used for filtering, sorting, and searching.
Handle geo data
If your Elasticsearch documents use geo_point fields, convert them to Meilisearch’s _geo format:
JavaScript
Python
Ruby
function convertGeoFields(doc, geoFieldName) {
if (doc[geoFieldName]) {
const geo = doc[geoFieldName];
doc._geo = {
lat: geo.lat,
lng: geo.lon, // Elasticsearch uses "lon", Meilisearch uses "lng"
};
delete doc[geoFieldName];
}
return doc;
}
def convert_geo_fields(doc, geo_field_name):
if geo_field_name in doc:
geo = doc[geo_field_name]
doc["_geo"] = {
"lat": geo["lat"],
"lng": geo["lon"], # Elasticsearch uses "lon", Meilisearch uses "lng"
}
del doc[geo_field_name]
return doc
def convert_geo_fields(doc, geo_field_name)
if doc[geo_field_name]
geo = doc[geo_field_name]
doc['_geo'] = {
'lat' => geo['lat'],
'lng' => geo['lon'] # Elasticsearch uses "lon", Meilisearch uses "lng"
}
doc.delete(geo_field_name)
end
doc
end
Import your data into Meilisearch
Create Meilisearch client
Create a Meilisearch client by passing the host URL and API key of your Meilisearch instance. The easiest option is to use the automatically generated admin API key.
JavaScript
Python
Ruby
const { Meilisearch } = require("meilisearch");
const meiliClient = new Meilisearch({
host: "MEILI_HOST",
apiKey: "MEILI_API_KEY",
});
const meiliIndex = meiliClient.index("MEILI_INDEX_NAME");
import meilisearch
meili_client = meilisearch.Client("MEILI_HOST", "MEILI_API_KEY")
meili_index = meili_client.index("MEILI_INDEX_NAME")
require 'meilisearch'
meili_client = MeiliSearch::Client.new('MEILI_HOST', 'MEILI_API_KEY')
meili_index = meili_client.index('MEILI_INDEX_NAME')
Replace MEILI_HOST, MEILI_API_KEY, and MEILI_INDEX_NAME with your Meilisearch host URL, API key, and target index name. Meilisearch will create the index if it doesn’t already exist.
Upload data to Meilisearch
Use the Meilisearch client method addDocumentsInBatches to upload all records in batches of 100,000.
JavaScript
Python
Ruby
const UPLOAD_BATCH_SIZE = 100000;
await meiliIndex.addDocumentsInBatches(documents, UPLOAD_BATCH_SIZE);
UPLOAD_BATCH_SIZE = 100000
meili_index.add_documents_in_batches(documents, batch_size=UPLOAD_BATCH_SIZE)
UPLOAD_BATCH_SIZE = 100000
meili_index.add_documents_in_batches(documents, UPLOAD_BATCH_SIZE)
When you’re ready, run the script:
JavaScript
Python
Ruby
node script.js
python script.py
ruby script.rb
Finished script
JavaScript
Python
Ruby
const { Client } = require("@elastic/elasticsearch");
const { Meilisearch } = require("meilisearch");
const ES_INDEX = "YOUR_INDEX_NAME";
const FETCH_BATCH_SIZE = 10000;
const UPLOAD_BATCH_SIZE = 100000;
(async () => {
// Connect to Elasticsearch
const esClient = new Client({
node: "ELASTICSEARCH_URL",
auth: {
apiKey: "ELASTICSEARCH_API_KEY",
},
});
// Fetch all documents using Point in Time
const records = [];
const pit = await esClient.openPointInTime({
index: ES_INDEX,
keep_alive: "5m",
});
let searchAfter;
while (true) {
const response = await esClient.search({
body: {
size: FETCH_BATCH_SIZE,
query: { match_all: {} },
pit: { id: pit.id, keep_alive: "5m" },
sort: [{ _doc: "asc" }],
...(searchAfter && { search_after: searchAfter }),
},
});
const hits = response.hits.hits;
if (hits.length === 0) break;
records.push(...hits);
searchAfter = hits[hits.length - 1].sort;
}
await esClient.closePointInTime({ id: pit.id });
// Prepare documents for Meilisearch
const documents = records.map((hit) => {
const doc = hit._source;
doc.id = hit._id;
return doc;
});
console.log(`Fetched ${documents.length} documents from Elasticsearch`);
// Upload to Meilisearch
const meiliClient = new Meilisearch({
host: "MEILI_HOST",
apiKey: "MEILI_API_KEY",
});
const meiliIndex = meiliClient.index("MEILI_INDEX_NAME");
await meiliIndex.addDocumentsInBatches(documents, UPLOAD_BATCH_SIZE);
console.log("Migration complete");
})();
from elasticsearch import Elasticsearch
import meilisearch
ES_INDEX = "YOUR_INDEX_NAME"
FETCH_BATCH_SIZE = 10000
UPLOAD_BATCH_SIZE = 100000
## Connect to Elasticsearch
es_client = Elasticsearch(
"ELASTICSEARCH_URL",
api_key="ELASTICSEARCH_API_KEY",
)
## Fetch all documents using Point in Time
records = []
pit = es_client.open_point_in_time(index=ES_INDEX, keep_alive="5m")
search_after = None
while True:
body = {
"size": FETCH_BATCH_SIZE,
"query": {"match_all": {}},
"pit": {"id": pit["id"], "keep_alive": "5m"},
"sort": [{"_doc": "asc"}],
}
if search_after:
body["search_after"] = search_after
response = es_client.search(body=body)
hits = response["hits"]["hits"]
if not hits:
break
records.extend(hits)
search_after = hits[-1]["sort"]
es_client.close_point_in_time(id=pit["id"])
## Prepare documents for Meilisearch
documents = []
for hit in records:
doc = hit["_source"]
doc["id"] = hit["_id"]
documents.append(doc)
print(f"Fetched {len(documents)} documents from Elasticsearch")
## Upload to Meilisearch
meili_client = meilisearch.Client("MEILI_HOST", "MEILI_API_KEY")
meili_index = meili_client.index("MEILI_INDEX_NAME")
meili_index.add_documents_in_batches(documents, batch_size=UPLOAD_BATCH_SIZE)
print("Migration complete")
require 'elasticsearch'
require 'meilisearch'
ES_INDEX = 'YOUR_INDEX_NAME'
FETCH_BATCH_SIZE = 10_000
UPLOAD_BATCH_SIZE = 100_000
## Connect to Elasticsearch
es_client = Elasticsearch::Client.new(
url: 'ELASTICSEARCH_URL',
api_key: 'ELASTICSEARCH_API_KEY'
)
## Fetch all documents using Point in Time
records = []
pit = es_client.open_point_in_time(index: ES_INDEX, keep_alive: '5m')
search_after = nil
loop do
body = {
size: FETCH_BATCH_SIZE,
query: { match_all: {} },
pit: { id: pit['id'], keep_alive: '5m' },
sort: [{ _doc: 'asc' }]
}
body[:search_after] = search_after if search_after
response = es_client.search(body: body)
hits = response['hits']['hits']
break if hits.empty?
records.concat(hits)
search_after = hits.last['sort']
end
es_client.close_point_in_time(body: { id: pit['id'] })
## Prepare documents for Meilisearch
documents = records.map do |hit|
doc = hit['_source']
doc['id'] = hit['_id']
doc
end
puts "Fetched #{documents.length} documents from Elasticsearch"
## Upload to Meilisearch
meili_client = MeiliSearch::Client.new('MEILI_HOST', 'MEILI_API_KEY')
meili_index = meili_client.index('MEILI_INDEX_NAME')
meili_index.add_documents_in_batches(documents, UPLOAD_BATCH_SIZE)
puts 'Migration complete'
Configure your index settings
Meilisearch’s default settings deliver relevant, typo-tolerant search out of the box. However, if your Elasticsearch index relies on specific mappings or analyzers, you may want to configure equivalent Meilisearch settings. To customize your index settings, see configuring index settings. To understand the differences between Elasticsearch and Meilisearch settings, read on.
Key conceptual differences
Elasticsearch uses explicit mappings to define how each field is indexed, analyzed, and stored. You must configure analyzers, tokenizers, and field types before indexing data. Search behavior is controlled through a complex Query DSL. Meilisearch takes a different approach: all fields are automatically indexed and searchable by default. You refine behavior through index settings (which affect all searches) and search parameters (which affect a single query). Features like typo tolerance, prefix search, and ranking work out of the box without configuration. This means many Elasticsearch configurations have no direct equivalent in Meilisearch because the behavior is automatic. For example, you don’t need to configure analyzers for typo tolerance, prefix matching, or stop words: Meilisearch handles these by default.
Settings and parameters comparison
The below tables compare Elasticsearch mappings, settings, and query parameters with the equivalent Meilisearch features.
Index mappings and field configuration
| Elasticsearch | Meilisearch | Notes |
|---|---|---|
mappings.properties (field types) |
Automatic | Meilisearch infers field types automatically |
properties.*.type: “text” |
searchableAttributes |
All fields are searchable by default; use this setting to restrict or reorder |
properties.*.type: “keyword” |
filterableAttributes |
Add fields you want to filter or facet on |
properties.*.index: false |
displayedAttributes |
Control which fields appear in results |
properties.*.type: “geo_point” |
_geo field with lat/lng |
Add _geo to filterableAttributes and sortableAttributes |
properties.*.type: “nested” |
Flatten to top-level fields | Meilisearch does not support nested object queries |
_source.excludes |
displayedAttributes |
Only list the fields you want returned |
enabled: false |
Omit from searchableAttributes |
Fields are still stored but not searched |
Analysis and text processing
| Elasticsearch | Meilisearch | Notes |
|---|---|---|
analysis.analyzer |
Automatic | Meilisearch uses a built-in language-aware analyzer |
analysis.tokenizer |
separatorTokens / nonSeparatorTokens |
Customize word boundary behavior |
analysis.filter.stop |
stopWords |
Define words to ignore during search |
analysis.filter.synonym |
synonyms |
Define equivalent terms |
analysis.filter.stemmer |
Automatic | Built-in stemming via language detection |
settings.index.analysis.normalizer |
Automatic | Meilisearch normalizes Unicode, casing, and diacritics automatically |
| Language-specific analyzers | localizedAttributes |
Assign languages to specific fields |
Search query parameters
| Elasticsearch | Meilisearch | Notes |
|---|---|---|
query.match / query.multi_match |
q search param |
Meilisearch searches all searchableAttributes by default |
query.term / query.terms |
filter search param |
Use filter expressions for exact matches |
query.bool.filter |
filter search param |
Supports AND, OR, NOT, () operators |
query.bool.must / should / must_not |
filter + q |
Combine search query with filter expressions |
query.range |
filter search param |
Use operators like field > value or field value1 TO value2 |
query.fuzzy / fuzziness |
Automatic | Built-in typo tolerance, configurable per index |
query.prefix |
Automatic | Built-in prefix search on the last query word |
query.knn |
hybrid + vector search params |
Requires embedders setting |
query.geo_distance |
_geoRadius(lat, lng, radius) in filter |
Requires _geo in filterableAttributes |
query.geo_bounding_box |
_geoBoundingBox([lat, lng], [lat, lng]) in filter |
Requires _geo in filterableAttributes |
highlight |
attributesToHighlight + highlightPreTag + highlightPostTag |
Search params |
_source |
attributesToRetrieve |
Search param |
from / size |
offset / limit or page / hitsPerPage |
Search params |
sort |
sort search param |
Requires sortableAttributes setting |
search_after |
offset / limit or page / hitsPerPage |
Meilisearch uses simpler pagination |
aggs (aggregations) |
facets search param |
Returns value counts; complex aggregations are not supported |
explain |
showRankingScore / showRankingScoreDetails |
Search params |
collapse |
distinct search param or distinctAttribute setting |
Field-level deduplication |
min_score |
rankingScoreThreshold |
Search param |
Index settings
| Elasticsearch | Meilisearch | Notes |
|---|---|---|
index.number_of_replicas |
Automatic (Meilisearch Cloud) | Meilisearch Cloud handles replication |
index.number_of_shards |
Automatic (Meilisearch Cloud) | Meilisearch Cloud handles sharding |
index.max_result_window |
pagination.maxTotalHits |
Default is 1000 in Meilisearch |
index.refresh_interval |
Automatic | Meilisearch indexes asynchronously via tasks |
What you can simplify
Many Elasticsearch configurations become unnecessary when migrating to Meilisearch:
- Analyzers and tokenizers: Meilisearch’s built-in text processing handles tokenization, normalization, stemming, and language detection automatically.
- Mapping definitions: Field types are inferred. You don’t need to define mappings before indexing documents.
- Replicas and shards: Meilisearch Cloud manages these automatically. Self-hosted instances run as a single process.
- Index lifecycle management: Meilisearch doesn’t require index rotation, rollover policies, or shard management.
- Query complexity: Most Elasticsearch
boolqueries with nestedmust,should, andfilterclauses translate to a simpleqparameter combined with afilterstring.
Query comparison
This section shows how common Elasticsearch queries translate to Meilisearch.
Full-text search
Elasticsearch:
{
"query": {
"match": {
"title": "search engine"
}
}
}
Meilisearch:
{
"q": "search engine"
}
Meilisearch searches all searchableAttributes by default. To restrict to a specific field, use the attributesToSearchOn search parameter.
Filtering
Elasticsearch:
{
"query": {
"bool": {
"must": { "match": { "title": "search" } },
"filter": [
{ "term": { "status": "published" } },
{ "range": { "price": { "gte": 10, "lte": 50 } } }
]
}
}
}
Meilisearch:
{
"q": "search",
"filter": "status = published AND price >= 10 AND price <= 50"
}
Attributes used in filter must first be added to filterableAttributes.
Sorting
Elasticsearch:
{
"query": { "match_all": {} },
"sort": [
{ "price": "asc" },
{ "date": "desc" }
]
}
Meilisearch:
{
"q": "",
"sort": ["price:asc", "date:desc"]
}
Attributes used in sort must first be added to sortableAttributes.
Faceted search
Elasticsearch:
{
"query": { "match": { "title": "shoes" } },
"aggs": {
"colors": { "terms": { "field": "color" } },
"price_ranges": {
"range": {
"field": "price",
"ranges": [
{ "to": 50 },
{ "from": 50, "to": 100 },
{ "from": 100 }
]
}
}
}
}
Meilisearch:
{
"q": "shoes",
"facets": ["color", "price"]
}
Meilisearch returns value distributions for each facet. Range aggregations are not supported: use filter to narrow results by range.
Geo search
Elasticsearch:
{
"query": {
"geo_distance": {
"distance": "10km",
"location": { "lat": 48.8566, "lon": 2.3522 }
}
},
"sort": [
{ "_geo_distance": { "location": { "lat": 48.8566, "lon": 2.3522 }, "order": "asc" } }
]
}
Meilisearch:
{
"filter": "_geoRadius(48.8566, 2.3522, 10000)",
"sort": ["_geoPoint(48.8566, 2.3522):asc"]
}
The _geo attribute must be added to both filterableAttributes and sortableAttributes.
API methods
This section compares Elasticsearch and Meilisearch API operations.
| Operation | Elasticsearch | Meilisearch |
|---|---|---|
| Create index | PUT /my-index |
POST /indexes |
| Delete index | DELETE /my-index |
DELETE /indexes/{index_uid} |
| Get index info | GET /my-index |
GET /indexes/{index_uid} |
| List indexes | GET /_cat/indices |
GET /indexes |
| Index document | POST /my-index/_doc |
POST /indexes/{index_uid}/documents |
| Bulk index | POST /_bulk |
POST /indexes/{index_uid}/documents (accepts arrays) |
| Get document | GET /my-index/_doc/{id} |
GET /indexes/{index_uid}/documents/{id} |
| Delete document | DELETE /my-index/_doc/{id} |
DELETE /indexes/{index_uid}/documents/{id} |
| Delete by query | POST /my-index/_delete_by_query |
POST /indexes/{index_uid}/documents/delete |
| Search | POST /my-index/_search |
POST /indexes/{index_uid}/search |
| Multi-search | POST /_msearch |
POST /multi-search |
| Get settings | GET /my-index/_settings |
GET /indexes/{index_uid}/settings |
| Update settings | PUT /my-index/_settings |
PATCH /indexes/{index_uid}/settings |
| Create API key | POST /_security/api_key |
POST /keys |
| Get cluster health | GET /_cluster/health |
GET /health |
| Get task status | GET /_tasks/{task_id} |
GET /tasks/{task_uid} |
Front-end components
Elasticsearch offers Search UI, a React component library for building search interfaces. Meilisearch is compatible with Algolia’s InstantSearch libraries through . InstantSearch provides a rich set of pre-built widgets for search boxes, hits, facets, pagination, and more. You can find an up-to-date list of in the GitHub project’s README.
