Comprehensive Guide to Elasticsearch Mapping

Practical guide for designing Elasticsearch mappings, index settings, templates, and Node.js index operations.

Target audience: application developers who need predictable search behavior, safe schema evolution, and production-ready index management.

Examples use the typeless Elasticsearch API style used by modern Elasticsearch versions and the official Node.js client @elastic/elasticsearch.


Table of Contents

  1. What Is Mapping?
  2. Mapping vs Settings vs Templates
  3. Mapping Lifecycle
  4. Core Field Types
  5. Text, Keyword, and Multi-fields
  6. Index Analysis Settings
  7. Dynamic Mapping
  8. Dynamic Templates
  9. Objects, Nested, Flattened
  10. Geo, IP, Date, Range, Completion, Vector
  11. Common Mapping Parameters
  12. Index Templates and Component Templates
  13. Aliases, Reindexing, and Zero-downtime Migration
  14. Full Example: Real Estate Property Search Index
  15. Node.js: Managing Indices
  16. Node.js: Indexing and Searching Documents
  17. Testing Mappings and Analyzers
  18. Production Checklist
  19. Common Mistakes
  20. References

1. What Is Mapping?

In Elasticsearch, a mapping is the schema definition for documents in an index.

It tells Elasticsearch:

A relational database table might look like this:

CREATE TABLE properties (
  id BIGINT PRIMARY KEY,
  title VARCHAR(255),
  price BIGINT,
  created_at DATETIME
);

A similar Elasticsearch mapping might look like this:

PUT properties-v1
{
  "mappings": {
    "dynamic": "strict",
    "properties": {
      "id": { "type": "keyword" },
      "title": {
        "type": "text",
        "fields": {
          "keyword": { "type": "keyword", "ignore_above": 256 }
        }
      },
      "price": { "type": "long" },
      "created_at": { "type": "date" }
    }
  }
}

Key idea

Mapping is not just about storage. It directly affects search behavior.

For example:


2. Mapping vs Settings vs Templates

Mapping

Mapping defines fields and how they are indexed.

{
  "mappings": {
    "properties": {
      "title": { "type": "text" },
      "status": { "type": "keyword" }
    }
  }
}

Settings

Settings define index-level behavior such as shards, replicas, refresh interval, and custom analyzers.

{
  "settings": {
    "number_of_shards": 1,
    "number_of_replicas": 1,
    "refresh_interval": "1s"
  }
}

Templates

Templates automatically apply settings, mappings, and aliases when matching indices are created.

PUT _index_template/properties-template
{
  "index_patterns": ["properties-*"] ,
  "priority": 100,
  "template": {
    "settings": {
      "number_of_shards": 1
    },
    "mappings": {
      "properties": {
        "id": { "type": "keyword" }
      }
    },
    "aliases": {
      "properties-read": {}
    }
  }
}

Mermaid: relationship between settings, mapping, and templates

flowchart TD
    A[Index Template] --> B[Settings]
    A --> C[Mappings]
    A --> D[Aliases]
    B --> E[New Index]
    C --> E
    D --> E
    F[Documents] --> E
    E --> G[Search / Aggregation / Sort]

3. Mapping Lifecycle

A good production workflow is:

  1. design the mapping before indexing data;
  2. create index or index template;
  3. index sample documents;
  4. test _analyze, queries, sorting, and aggregations;
  5. only then index full production data;
  6. if a field type must change later, create a new index and reindex.

Important limitation

In most cases, you cannot change the type of an existing mapped field. For example, changing price from keyword to long requires creating a new index and reindexing data.

You can usually add new fields, add properties to object fields, enable multi-fields in supported cases, and update only certain mapping parameters.

Mermaid: mapping lifecycle

flowchart LR
    A[Design Mapping] --> B[Create Index or Template]
    B --> C[Index Sample Docs]
    C --> D[Test Queries]
    D --> E{Correct?}
    E -- Yes --> F[Index Production Data]
    E -- No --> G[Delete Test Index / Create New Version]
    G --> B
    F --> H{Need Field Type Change?}
    H -- Yes --> I[Create v2 Index]
    I --> J[Reindex]
    J --> K[Switch Alias]
    H -- No --> L[Add New Fields If Needed]

4. Core Field Types

Common field type table

Data shape Recommended type Typical use
Human-readable body text text full-text search
ID, enum, code, status keyword exact match, filter, aggregation, sort
Large machine-generated string wildcard log paths, stack traces, unknown machine strings
Integer number integer, long counts, IDs if numeric operations are needed
Decimal number float, double, scaled_float price, area, score
Money scaled_float or long avoid floating-point rounding surprises
Boolean boolean flags
Timestamp date, date_nanos date range queries, sorting
IP address ip network filtering
Latitude/longitude geo_point distance and map queries
Polygon/shape geo_shape complex geospatial queries
Arbitrary JSON object flattened unknown keys, metadata, attributes
Array of independent objects nested preserve object relationships
Dense embedding vector dense_vector semantic/vector search
Autocomplete suggestion completion or search-as-you-type strategy typeahead

Numeric example

PUT products-v1
{
  "mappings": {
    "properties": {
      "product_id": { "type": "keyword" },
      "stock_count": { "type": "integer" },
      "price_krw": { "type": "long" },
      "discount_rate": { "type": "scaled_float", "scaling_factor": 100 }
    }
  }
}

With scaled_float, a value like 12.34 can be stored internally as 1234 when scaling_factor is 100.

Date example

PUT events-v1
{
  "mappings": {
    "properties": {
      "created_at": {
        "type": "date",
        "format": "strict_date_optional_time||epoch_millis"
      },
      "event_day": {
        "type": "date",
        "format": "yyyy-MM-dd"
      }
    }
  }
}

5. Text, Keyword, and Multi-fields

text

Use text for full-text search.

"description": {
  "type": "text"
}

A text field is analyzed. For example, a string may be lowercased, tokenized, and indexed as separate terms.

keyword

Use keyword for exact matching, aggregations, sorting, and filters.

"status": {
  "type": "keyword"
}

Good examples:

Multi-fields

A common pattern is to map a string as text and also as keyword.

"title": {
  "type": "text",
  "fields": {
    "keyword": {
      "type": "keyword",
      "ignore_above": 256
    }
  }
}

Use it like this:

GET properties-v1/_search
{
  "query": {
    "match": {
      "title": "commercial building"
    }
  },
  "sort": [
    { "title.keyword": "asc" }
  ],
  "aggs": {
    "titles": {
      "terms": { "field": "title.keyword" }
    }
  }
}

Mermaid: text vs keyword

flowchart TD
    A[Original String: Seoul Office Building] --> B[text field]
    A --> C[keyword field]
    B --> D[Analyze]
    D --> E[Tokens: seoul, office, building]
    C --> F[Exact Value: Seoul Office Building]
    E --> G[Full-text match query]
    F --> H[term filter / sort / aggregation]

6. Index Analysis Settings

Analyzers are defined in index settings and referenced by mappings.

Basic lowercase + ASCII folding analyzer

PUT articles-v1
{
  "settings": {
    "analysis": {
      "analyzer": {
        "folding_text_analyzer": {
          "type": "custom",
          "tokenizer": "standard",
          "filter": ["lowercase", "asciifolding"]
        }
      },
      "normalizer": {
        "lowercase_keyword_normalizer": {
          "type": "custom",
          "filter": ["lowercase", "asciifolding"]
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "title": {
        "type": "text",
        "analyzer": "folding_text_analyzer",
        "fields": {
          "keyword": {
            "type": "keyword",
            "normalizer": "lowercase_keyword_normalizer"
          }
        }
      }
    }
  }
}

Search-as-you-type with edge n-gram

Use this for prefix search such as seoseoul.

PUT autocomplete-v1
{
  "settings": {
    "analysis": {
      "analyzer": {
        "autocomplete_index": {
          "type": "custom",
          "tokenizer": "autocomplete_tokenizer",
          "filter": ["lowercase"]
        },
        "autocomplete_search": {
          "type": "custom",
          "tokenizer": "standard",
          "filter": ["lowercase"]
        }
      },
      "tokenizer": {
        "autocomplete_tokenizer": {
          "type": "edge_ngram",
          "min_gram": 2,
          "max_gram": 20,
          "token_chars": ["letter", "digit"]
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "name": {
        "type": "text",
        "analyzer": "autocomplete_index",
        "search_analyzer": "autocomplete_search",
        "fields": {
          "keyword": { "type": "keyword", "ignore_above": 256 }
        }
      }
    }
  }
}

N-gram for partial matching

Use n-gram carefully. It can increase index size significantly.

PUT partial-match-v1
{
  "settings": {
    "analysis": {
      "tokenizer": {
        "custom_ngram_tokenizer": {
          "type": "ngram",
          "min_gram": 2,
          "max_gram": 3,
          "token_chars": ["letter", "digit"]
        }
      },
      "analyzer": {
        "custom_ngram_analyzer": {
          "type": "custom",
          "tokenizer": "custom_ngram_tokenizer",
          "filter": ["lowercase"]
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "search_text": {
        "type": "text",
        "analyzer": "custom_ngram_analyzer",
        "search_analyzer": "standard"
      }
    }
  }
}

Korean text with Nori analyzer

For Korean search, consider the official Nori analysis plugin. Availability depends on your deployment type and plugin support.

PUT korean-properties-v1
{
  "settings": {
    "analysis": {
      "analyzer": {
        "korean_nori": {
          "type": "custom",
          "tokenizer": "nori_tokenizer",
          "filter": ["nori_part_of_speech", "lowercase"]
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "address": {
        "type": "text",
        "analyzer": "korean_nori",
        "fields": {
          "keyword": { "type": "keyword", "ignore_above": 512 }
        }
      }
    }
  }
}

Mermaid: analyzer pipeline

flowchart LR
    A[Input Text] --> B[Character Filters]
    B --> C[Tokenizer]
    C --> D[Token Filters]
    D --> E[Indexed Terms]
    E --> F[Search Query Matching]

7. Dynamic Mapping

Dynamic mapping means Elasticsearch automatically detects and adds fields when new documents are indexed.

Default dynamic mapping example

POST dynamic-test/_doc/1
{
  "name": "Ryan",
  "age": 45,
  "active": true,
  "created_at": "2026-06-10T09:00:00+09:00"
}

Elasticsearch may automatically map fields as something like:

{
  "name": { "type": "text", "fields": { "keyword": { "type": "keyword" } } },
  "age": { "type": "long" },
  "active": { "type": "boolean" },
  "created_at": { "type": "date" }
}

Dynamic modes

dynamic value Behavior Recommended use
true new fields are added automatically early prototyping
false unknown fields are ignored for indexing but remain in _source controlled schema with flexible source payload
strict unknown fields reject the document production critical indices
runtime new fields become runtime fields exploratory data, lower indexing cost

Strict mapping example

PUT strict-users-v1
{
  "mappings": {
    "dynamic": "strict",
    "properties": {
      "user_id": { "type": "keyword" },
      "name": { "type": "text" }
    }
  }
}

This document succeeds:

POST strict-users-v1/_doc/1
{
  "user_id": "u-1",
  "name": "Ryan"
}

This document fails because unknown_field is not mapped:

POST strict-users-v1/_doc/2
{
  "user_id": "u-2",
  "name": "Alex",
  "unknown_field": "not allowed"
}

Dynamic false example

PUT flexible-source-v1
{
  "mappings": {
    "dynamic": false,
    "properties": {
      "id": { "type": "keyword" },
      "title": { "type": "text" }
    }
  }
}

Unknown fields remain in _source, but are not searchable unless explicitly mapped later and reindexed.


8. Dynamic Templates

Dynamic templates let you control how dynamically discovered fields are mapped.

Example: map all *_id fields as keyword

PUT dynamic-template-v1
{
  "mappings": {
    "dynamic_templates": [
      {
        "ids_as_keyword": {
          "match": "*_id",
          "mapping": {
            "type": "keyword"
          }
        }
      }
    ]
  }
}

If you index:

POST dynamic-template-v1/_doc/1
{
  "user_id": "123",
  "order_id": "A-999"
}

Then both user_id and order_id become keyword fields.

Example: map string fields as keyword only

Useful for logs or structured data where full-text search is not needed.

PUT logs-structured-v1
{
  "mappings": {
    "dynamic_templates": [
      {
        "strings_as_keywords": {
          "match_mapping_type": "string",
          "mapping": {
            "type": "keyword",
            "ignore_above": 1024
          }
        }
      }
    ]
  }
}

Example: map unknown objects as flattened

PUT flexible-metadata-v1
{
  "mappings": {
    "dynamic_templates": [
      {
        "metadata_objects_as_flattened": {
          "path_match": "metadata.*",
          "match_mapping_type": "object",
          "mapping": {
            "type": "flattened"
          }
        }
      }
    ],
    "properties": {
      "metadata": {
        "type": "object",
        "dynamic": true
      }
    }
  }
}

Mermaid: dynamic mapping decision

flowchart TD
    A[Incoming Document] --> B{Field already mapped?}
    B -- Yes --> C[Use existing mapping]
    B -- No --> D{dynamic setting}
    D -- strict --> E[Reject document]
    D -- false --> F[Keep in _source only]
    D -- runtime --> G[Create runtime field]
    D -- true --> H{Dynamic template match?}
    H -- Yes --> I[Apply template mapping]
    H -- No --> J[Apply default dynamic mapping]

9. Objects, Nested, Flattened

Object field

Use object for normal JSON objects.

PUT users-v1
{
  "mappings": {
    "properties": {
      "profile": {
        "properties": {
          "first_name": { "type": "keyword" },
          "last_name": { "type": "keyword" }
        }
      }
    }
  }
}

Problem with arrays of objects

Elasticsearch flattens object fields internally. This can lose the association between fields in arrays of objects.

Example document:

{
  "owners": [
    { "name": "Alice", "role": "seller" },
    { "name": "Bob", "role": "buyer" }
  ]
}

A normal object mapping can incorrectly match:

Because the association between Alice and seller can be lost.

Nested field

Use nested when each object in an array must be queried independently.

PUT properties-with-owners-v1
{
  "mappings": {
    "properties": {
      "owners": {
        "type": "nested",
        "properties": {
          "name": { "type": "keyword" },
          "role": { "type": "keyword" },
          "share_percent": { "type": "scaled_float", "scaling_factor": 100 }
        }
      }
    }
  }
}

Nested query:

GET properties-with-owners-v1/_search
{
  "query": {
    "nested": {
      "path": "owners",
      "query": {
        "bool": {
          "must": [
            { "term": { "owners.name": "Alice" } },
            { "term": { "owners.role": "seller" } }
          ]
        }
      }
    }
  }
}

Flattened field

Use flattened for arbitrary key-value objects where you do not want mapping explosion.

PUT products-with-attributes-v1
{
  "mappings": {
    "properties": {
      "attributes": {
        "type": "flattened"
      }
    }
  }
}

Example document:

POST products-with-attributes-v1/_doc/1
{
  "attributes": {
    "floor": "12",
    "parking": "available",
    "custom_owner_note": "near station"
  }
}

Query:

GET products-with-attributes-v1/_search
{
  "query": {
    "term": {
      "attributes.parking": "available"
    }
  }
}

Mermaid: object vs nested

flowchart TD
    A[Array of Objects] --> B{Need to preserve each object's internal relationship?}
    B -- No --> C[object]
    B -- Yes --> D[nested]
    A --> E{Many arbitrary keys?}
    E -- Yes --> F[flattened]

10. Geo, IP, Date, Range, Completion, Vector

geo_point

Use geo_point for latitude/longitude.

PUT places-v1
{
  "mappings": {
    "properties": {
      "name": { "type": "text" },
      "location": { "type": "geo_point" }
    }
  }
}

Index document:

POST places-v1/_doc/1
{
  "name": "Gangnam Station",
  "location": {
    "lat": 37.4979,
    "lon": 127.0276
  }
}

Distance query:

GET places-v1/_search
{
  "query": {
    "geo_distance": {
      "distance": "1km",
      "location": {
        "lat": 37.4979,
        "lon": 127.0276
      }
    }
  }
}

ip

PUT access-logs-v1
{
  "mappings": {
    "properties": {
      "client_ip": { "type": "ip" },
      "path": { "type": "keyword" }
    }
  }
}

Range fields

PUT availability-v1
{
  "mappings": {
    "properties": {
      "available_price": { "type": "integer_range" },
      "available_date": { "type": "date_range" }
    }
  }
}

Example document:

POST availability-v1/_doc/1
{
  "available_price": { "gte": 100000, "lte": 300000 },
  "available_date": { "gte": "2026-06-01", "lte": "2026-06-30" }
}

Completion suggester

PUT suggest-v1
{
  "mappings": {
    "properties": {
      "name": { "type": "text" },
      "name_suggest": { "type": "completion" }
    }
  }
}

Index document:

POST suggest-v1/_doc/1
{
  "name": "Seoul Station Office",
  "name_suggest": {
    "input": ["Seoul Station Office", "Station Office"]
  }
}

Suggest query:

GET suggest-v1/_search
{
  "suggest": {
    "name-suggest": {
      "prefix": "seo",
      "completion": {
        "field": "name_suggest"
      }
    }
  }
}

dense_vector

Use dense_vector for embeddings and kNN search.

PUT property-vectors-v1
{
  "mappings": {
    "properties": {
      "title": { "type": "text" },
      "embedding": {
        "type": "dense_vector",
        "dims": 384,
        "similarity": "cosine"
      }
    }
  }
}

kNN search example:

GET property-vectors-v1/_search
{
  "knn": {
    "field": "embedding",
    "query_vector": [0.12, -0.04, 0.98],
    "k": 10,
    "num_candidates": 100
  },
  "fields": ["title"]
}

Mermaid: vector search flow

flowchart LR
    A[Text Document] --> B[Embedding Model]
    B --> C[Vector]
    C --> D[Elasticsearch dense_vector Field]
    E[User Query] --> F[Embedding Model]
    F --> G[Query Vector]
    G --> H[kNN Search]
    D --> H
    H --> I[Nearest Documents]

11. Common Mapping Parameters

index

Whether the field is searchable.

"internal_note": {
  "type": "keyword",
  "index": false
}

The field is stored in _source, but cannot be searched.

doc_values

doc_values are columnar structures used for sorting, aggregations, and scripting.

"description": {
  "type": "keyword",
  "doc_values": false
}

Usually keep defaults unless you know the field will never be sorted or aggregated.

ignore_above

Prevents indexing very long keyword values.

"raw_url": {
  "type": "keyword",
  "ignore_above": 2048
}

null_value

Index a replacement value when a field is explicitly null.

"status": {
  "type": "keyword",
  "null_value": "UNKNOWN"
}

copy_to

Copy multiple fields into one searchable field.

PUT searchable-users-v1
{
  "mappings": {
    "properties": {
      "first_name": { "type": "text", "copy_to": "full_text" },
      "last_name": { "type": "text", "copy_to": "full_text" },
      "email": { "type": "keyword", "copy_to": "full_text" },
      "full_text": { "type": "text" }
    }
  }
}

enabled

Disable parsing for an object field.

"raw_payload": {
  "type": "object",
  "enabled": false
}

Use this when you want to store raw JSON but do not want Elasticsearch to parse/index it.

eager_global_ordinals

Can improve aggregation performance on high-use keyword fields at the cost of refresh overhead.

"category": {
  "type": "keyword",
  "eager_global_ordinals": true
}

12. Index Templates and Component Templates

Templates are essential when you create multiple indices with the same schema.

Use cases:

Component template: settings

PUT _component_template/ct-properties-settings
{
  "template": {
    "settings": {
      "number_of_shards": 1,
      "number_of_replicas": 1,
      "refresh_interval": "1s",
      "analysis": {
        "normalizer": {
          "lowercase_keyword": {
            "type": "custom",
            "filter": ["lowercase", "asciifolding"]
          }
        }
      }
    }
  }
}

Component template: mappings

PUT _component_template/ct-properties-mappings
{
  "template": {
    "mappings": {
      "dynamic": "strict",
      "properties": {
        "id": { "type": "keyword" },
        "title": {
          "type": "text",
          "fields": {
            "keyword": {
              "type": "keyword",
              "ignore_above": 256,
              "normalizer": "lowercase_keyword"
            }
          }
        },
        "created_at": { "type": "date" }
      }
    }
  }
}

Index template

PUT _index_template/it-properties
{
  "index_patterns": ["properties-*"] ,
  "priority": 200,
  "composed_of": [
    "ct-properties-settings",
    "ct-properties-mappings"
  ],
  "template": {
    "aliases": {
      "properties-read": {}
    }
  }
}

Mermaid: composable templates

flowchart TD
    A[Component Template: Settings] --> C[Index Template]
    B[Component Template: Mappings] --> C
    D[Component Template: Aliases] --> C
    C --> E[Index Pattern: properties-*]
    E --> F[properties-v1]
    E --> G[properties-v2]

13. Aliases, Reindexing, and Zero-downtime Migration

Use aliases to avoid hardcoding physical index names in your application.

Recommended pattern:

Create v1 with aliases

PUT properties-v1
{
  "aliases": {
    "properties-read": {},
    "properties-write": { "is_write_index": true }
  },
  "mappings": {
    "properties": {
      "id": { "type": "keyword" },
      "title": { "type": "text" }
    }
  }
}

Create v2 with improved mapping

PUT properties-v2
{
  "mappings": {
    "properties": {
      "id": { "type": "keyword" },
      "title": {
        "type": "text",
        "fields": {
          "keyword": { "type": "keyword", "ignore_above": 256 }
        }
      },
      "price_krw": { "type": "long" }
    }
  }
}

Reindex

POST _reindex
{
  "source": {
    "index": "properties-v1"
  },
  "dest": {
    "index": "properties-v2"
  }
}

Switch aliases atomically

POST _aliases
{
  "actions": [
    { "remove": { "index": "properties-v1", "alias": "properties-read" } },
    { "remove": { "index": "properties-v1", "alias": "properties-write" } },
    { "add": { "index": "properties-v2", "alias": "properties-read" } },
    { "add": { "index": "properties-v2", "alias": "properties-write", "is_write_index": true } }
  ]
}

Mermaid: alias migration

sequenceDiagram
    participant App
    participant Alias as properties-read/write
    participant V1 as properties-v1
    participant V2 as properties-v2

    App->>Alias: read/write using alias
    Alias->>V1: points to v1
    App->>V2: create v2 mapping
    V1->>V2: reindex data
    App->>Alias: atomic alias switch
    Alias->>V2: points to v2
    App->>Alias: continues without index-name change

14. Full Example: Real Estate Property Search Index

This example is designed for a property listing/search system.

Features:

PUT properties-v1
{
  "settings": {
    "number_of_shards": 1,
    "number_of_replicas": 1,
    "refresh_interval": "1s",
    "analysis": {
      "normalizer": {
        "lowercase_keyword": {
          "type": "custom",
          "filter": ["lowercase", "asciifolding"]
        }
      },
      "tokenizer": {
        "autocomplete_tokenizer": {
          "type": "edge_ngram",
          "min_gram": 2,
          "max_gram": 20,
          "token_chars": ["letter", "digit"]
        }
      },
      "analyzer": {
        "autocomplete_index": {
          "type": "custom",
          "tokenizer": "autocomplete_tokenizer",
          "filter": ["lowercase"]
        },
        "autocomplete_search": {
          "type": "custom",
          "tokenizer": "standard",
          "filter": ["lowercase"]
        },
        "address_search": {
          "type": "custom",
          "tokenizer": "standard",
          "filter": ["lowercase"]
        }
      }
    }
  },
  "aliases": {
    "properties-read": {},
    "properties-write": { "is_write_index": true }
  },
  "mappings": {
    "dynamic": "strict",
    "properties": {
      "obj_id": { "type": "keyword" },
      "obj_name": {
        "type": "text",
        "analyzer": "address_search",
        "copy_to": "all_text",
        "fields": {
          "keyword": {
            "type": "keyword",
            "ignore_above": 256,
            "normalizer": "lowercase_keyword"
          },
          "autocomplete": {
            "type": "text",
            "analyzer": "autocomplete_index",
            "search_analyzer": "autocomplete_search"
          }
        }
      },
      "obj_addr": {
        "type": "text",
        "analyzer": "address_search",
        "copy_to": "all_text",
        "fields": {
          "keyword": { "type": "keyword", "ignore_above": 512 }
        }
      },
      "obj_type": { "type": "keyword" },
      "status": { "type": "keyword" },
      "sale_status": { "type": "keyword" },
      "price_krw": { "type": "long" },
      "area_m2": { "type": "scaled_float", "scaling_factor": 100 },
      "total_area_m2": { "type": "scaled_float", "scaling_factor": 100 },
      "yield_rate": { "type": "scaled_float", "scaling_factor": 10000 },
      "built_year": { "type": "short" },
      "location": { "type": "geo_point" },
      "created_at": { "type": "date" },
      "updated_at": { "type": "date" },
      "owners": {
        "type": "nested",
        "properties": {
          "owner_name": { "type": "keyword", "ignore_above": 128 },
          "owner_type": { "type": "keyword" },
          "share_percent": { "type": "scaled_float", "scaling_factor": 100 }
        }
      },
      "attributes": { "type": "flattened" },
      "all_text": { "type": "text", "analyzer": "address_search" }
    }
  }
}

Example document

POST properties-write/_doc/obj-1001
{
  "obj_id": "obj-1001",
  "obj_name": "Gangnam Office Building",
  "obj_addr": "서울특별시 강남구 테헤란로 123",
  "obj_type": "office",
  "status": "active",
  "sale_status": "for_sale",
  "price_krw": 12500000000,
  "area_m2": 320.55,
  "total_area_m2": 1200.75,
  "yield_rate": 4.52,
  "built_year": 2008,
  "location": { "lat": 37.501, "lon": 127.039 },
  "created_at": "2026-06-10T09:00:00+09:00",
  "updated_at": "2026-06-10T09:00:00+09:00",
  "owners": [
    { "owner_name": "Kim", "owner_type": "individual", "share_percent": 50.0 },
    { "owner_name": "Lee", "owner_type": "individual", "share_percent": 50.0 }
  ],
  "attributes": {
    "parking": "available",
    "elevator": "yes",
    "near_subway": "yes"
  }
}
GET properties-read/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "multi_match": {
            "query": "강남 오피스",
            "fields": ["obj_name^3", "obj_addr^2", "all_text"]
          }
        }
      ],
      "filter": [
        { "term": { "obj_type": "office" } },
        { "term": { "sale_status": "for_sale" } },
        { "range": { "price_krw": { "gte": 1000000000, "lte": 20000000000 } } },
        {
          "geo_distance": {
            "distance": "3km",
            "location": { "lat": 37.501, "lon": 127.039 }
          }
        }
      ]
    }
  },
  "sort": [
    { "updated_at": "desc" },
    { "price_krw": "asc" }
  ],
  "aggs": {
    "by_type": {
      "terms": { "field": "obj_type" }
    }
  }
}

Mermaid: property search architecture

flowchart TD
    A[MySQL / Source DB] --> B[Transform / Normalize]
    B --> C[Bulk Index to properties-write]
    C --> D[Elasticsearch properties-v1]
    D --> E[properties-read Alias]
    F[Node.js API Server] --> E
    G[Web / Mobile Client] --> F
    E --> H[Full-text Search]
    E --> I[Filters]
    E --> J[Geo Search]
    E --> K[Aggregations]

15. Node.js: Managing Indices

Install

npm install @elastic/elasticsearch

Create client

// es-client.js
import { Client } from '@elastic/elasticsearch';

export const es = new Client({
  node: process.env.ELASTICSEARCH_URL,
  auth: {
    apiKey: process.env.ELASTICSEARCH_API_KEY
  }
});

For local development with username/password:

import { Client } from '@elastic/elasticsearch';

export const es = new Client({
  node: 'http://localhost:9200',
  auth: {
    username: 'elastic',
    password: process.env.ELASTIC_PASSWORD
  }
});

Create an index with settings and mappings

// create-properties-index.js
import { es } from './es-client.js';

const index = 'properties-v1';

async function main() {
  const exists = await es.indices.exists({ index });

  if (exists) {
    console.log(`${index} already exists`);
    return;
  }

  await es.indices.create({
    index,
    settings: {
      number_of_shards: 1,
      number_of_replicas: 1,
      refresh_interval: '1s',
      analysis: {
        normalizer: {
          lowercase_keyword: {
            type: 'custom',
            filter: ['lowercase', 'asciifolding']
          }
        }
      }
    },
    mappings: {
      dynamic: 'strict',
      properties: {
        obj_id: { type: 'keyword' },
        obj_name: {
          type: 'text',
          fields: {
            keyword: {
              type: 'keyword',
              ignore_above: 256,
              normalizer: 'lowercase_keyword'
            }
          }
        },
        price_krw: { type: 'long' },
        location: { type: 'geo_point' },
        created_at: { type: 'date' }
      }
    },
    aliases: {
      'properties-read': {},
      'properties-write': { is_write_index: true }
    }
  });

  console.log(`created ${index}`);
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

Get mapping

import { es } from './es-client.js';

const mapping = await es.indices.getMapping({
  index: 'properties-v1'
});

console.dir(mapping, { depth: null });

Get settings

import { es } from './es-client.js';

const settings = await es.indices.getSettings({
  index: 'properties-v1'
});

console.dir(settings, { depth: null });

Add a new field with putMapping

import { es } from './es-client.js';

await es.indices.putMapping({
  index: 'properties-v1',
  properties: {
    updated_at: { type: 'date' },
    sale_status: { type: 'keyword' }
  }
});

Do not use this to change existing field types. Create a new index and reindex instead.

Delete index

import { es } from './es-client.js';

await es.indices.delete({
  index: 'properties-v1'
});

Mermaid: Node.js index management

sequenceDiagram
    participant Dev as Developer Script
    participant ES as Elasticsearch
    Dev->>ES: indices.exists(index)
    ES-->>Dev: true / false
    alt index does not exist
        Dev->>ES: indices.create(settings, mappings, aliases)
        ES-->>Dev: acknowledged
    else index exists
        Dev->>Dev: skip or migrate
    end
    Dev->>ES: indices.getMapping(index)
    ES-->>Dev: current mapping

16. Node.js: Indexing and Searching Documents

Index one document

import { es } from './es-client.js';

await es.index({
  index: 'properties-write',
  id: 'obj-1001',
  document: {
    obj_id: 'obj-1001',
    obj_name: 'Gangnam Office Building',
    price_krw: 12500000000,
    location: { lat: 37.501, lon: 127.039 },
    created_at: new Date().toISOString()
  },
  refresh: 'wait_for'
});

Bulk index documents

import { es } from './es-client.js';

const docs = [
  {
    obj_id: 'obj-1001',
    obj_name: 'Gangnam Office Building',
    price_krw: 12500000000,
    location: { lat: 37.501, lon: 127.039 },
    created_at: '2026-06-10T09:00:00+09:00'
  },
  {
    obj_id: 'obj-1002',
    obj_name: 'Seoul Station Retail',
    price_krw: 7800000000,
    location: { lat: 37.556, lon: 126.972 },
    created_at: '2026-06-10T09:10:00+09:00'
  }
];

const operations = docs.flatMap((doc) => [
  { index: { _index: 'properties-write', _id: doc.obj_id } },
  doc
]);

const result = await es.bulk({
  refresh: 'wait_for',
  operations
});

if (result.errors) {
  const erroredDocuments = [];

  result.items.forEach((action, i) => {
    const operation = Object.keys(action)[0];
    if (action[operation].error) {
      erroredDocuments.push({
        status: action[operation].status,
        error: action[operation].error,
        document: docs[i]
      });
    }
  });

  console.error(JSON.stringify(erroredDocuments, null, 2));
}

Search with text query and filters

import { es } from './es-client.js';

const response = await es.search({
  index: 'properties-read',
  query: {
    bool: {
      must: [
        {
          match: {
            obj_name: 'office building'
          }
        }
      ],
      filter: [
        { range: { price_krw: { lte: 15000000000 } } }
      ]
    }
  },
  sort: [
    { price_krw: 'asc' }
  ]
});

console.log(response.hits.hits.map((hit) => hit._source));

Search by geo distance

import { es } from './es-client.js';

const response = await es.search({
  index: 'properties-read',
  query: {
    bool: {
      filter: [
        {
          geo_distance: {
            distance: '2km',
            location: {
              lat: 37.501,
              lon: 127.039
            }
          }
        }
      ]
    }
  }
});

console.log(response.hits.hits);

Nested query in Node.js

import { es } from './es-client.js';

const response = await es.search({
  index: 'properties-read',
  query: {
    nested: {
      path: 'owners',
      query: {
        bool: {
          must: [
            { term: { 'owners.owner_name': 'Kim' } },
            { term: { 'owners.owner_type': 'individual' } }
          ]
        }
      },
      inner_hits: {}
    }
  }
});

console.log(response.hits.hits);

kNN vector search in Node.js

import { es } from './es-client.js';

const queryVector = [0.12, -0.04, 0.98]; // use the correct dimension for your mapping

const response = await es.search({
  index: 'property-vectors-v1',
  knn: {
    field: 'embedding',
    query_vector: queryVector,
    k: 10,
    num_candidates: 100
  },
  fields: ['title']
});

console.log(response.hits.hits);

Reindex with Node.js

import { es } from './es-client.js';

await es.reindex({
  wait_for_completion: true,
  source: {
    index: 'properties-v1'
  },
  dest: {
    index: 'properties-v2'
  }
});

Atomic alias switch with Node.js

import { es } from './es-client.js';

await es.indices.updateAliases({
  actions: [
    { remove: { index: 'properties-v1', alias: 'properties-read' } },
    { remove: { index: 'properties-v1', alias: 'properties-write' } },
    { add: { index: 'properties-v2', alias: 'properties-read' } },
    { add: { index: 'properties-v2', alias: 'properties-write', is_write_index: true } }
  ]
});

17. Testing Mappings and Analyzers

Check analyzer output

GET properties-v1/_analyze
{
  "analyzer": "standard",
  "text": "Seoul Office Building"
}

Check custom analyzer

GET autocomplete-v1/_analyze
{
  "analyzer": "autocomplete_index",
  "text": "Gangnam"
}

Expected style of tokens:

ga
gan
gang
gangn
gangna
gangnam

Validate mapping

GET properties-v1/_mapping

Validate field capabilities

GET properties-v1/_field_caps?fields=price_krw,obj_name,obj_name.keyword

Explain a search result

GET properties-read/_explain/obj-1001
{
  "query": {
    "match": {
      "obj_name": "office"
    }
  }
}

Profile a query

GET properties-read/_search
{
  "profile": true,
  "query": {
    "match": {
      "obj_name": "office"
    }
  }
}

18. Production Checklist

Before creating the index

Before production indexing

For long-term maintenance


19. Common Mistakes

Mistake 1: using text for IDs

Bad:

"user_id": { "type": "text" }

Good:

"user_id": { "type": "keyword" }

Mistake 2: sorting on a text field

Bad:

"sort": [{ "title": "asc" }]

Good:

"sort": [{ "title.keyword": "asc" }]

Mistake 3: relying on dynamic mapping in production

Dynamic mapping is convenient, but production search behavior should be intentional.

Prefer:

"dynamic": "strict"

or:

"dynamic": false

Mistake 4: changing field type in-place

Bad idea:

PUT my-index/_mapping
{
  "properties": {
    "price": { "type": "long" }
  }
}

If price already exists as keyword, create a new index and reindex.

Mistake 5: using nested everywhere

nested is powerful but heavier than normal object fields. Use it only when each object in an array must be queried independently.

Mistake 6: n-gram everything

N-grams can increase index size and indexing cost. Use edge n-gram for prefix autocomplete, and reserve full n-gram for cases that truly need partial matching.

Mistake 7: mapping arbitrary metadata as normal object fields

Bad for unpredictable keys:

"metadata": { "type": "object", "dynamic": true }

Better:

"metadata": { "type": "flattened" }

Mistake 8: indexing vector fields without understanding cost

Vector indexing can be expensive. Test ingest speed, memory usage, recall quality, and query latency before committing to a production design.


20. References

Official Elastic documentation used as the basis for this guide:


Appendix A: Minimal Mapping Starter

PUT app-index-v1
{
  "settings": {
    "number_of_shards": 1,
    "number_of_replicas": 1
  },
  "aliases": {
    "app-index-read": {},
    "app-index-write": { "is_write_index": true }
  },
  "mappings": {
    "dynamic": "strict",
    "properties": {
      "id": { "type": "keyword" },
      "title": {
        "type": "text",
        "fields": {
          "keyword": { "type": "keyword", "ignore_above": 256 }
        }
      },
      "status": { "type": "keyword" },
      "created_at": { "type": "date" },
      "updated_at": { "type": "date" }
    }
  }
}

Appendix B: Minimal Node.js Starter

import { Client } from '@elastic/elasticsearch';

const es = new Client({
  node: process.env.ELASTICSEARCH_URL,
  auth: { apiKey: process.env.ELASTICSEARCH_API_KEY }
});

async function main() {
  await es.index({
    index: 'app-index-write',
    id: '1',
    document: {
      id: '1',
      title: 'Hello Elasticsearch',
      status: 'active',
      created_at: new Date().toISOString(),
      updated_at: new Date().toISOString()
    },
    refresh: 'wait_for'
  });

  const result = await es.search({
    index: 'app-index-read',
    query: {
      match: { title: 'elasticsearch' }
    }
  });

  console.log(result.hits.hits);
}

main().catch(console.error);