Elasticsearch – The Field Types That Matter

June 3, 202012 min readUpdated 8/23/2026

Elasticsearch has around forty field types. You need about eight, and one distinction among them causes more confusion than everything else combined. This lesson covers the types a real project actually uses, and the decision behind each one.

text and keyword

Both hold strings. They behave nothing alike, and understanding why is most of the work.

A text field is analysed: the value is split into terms, lowercased, and those terms go into the inverted index. "San Francisco" is stored as san and francisco. The original string is not searchable at all — only its terms are.

A keyword field is not analysed. The whole value becomes one term, exactly as given. "San Francisco" is stored as San Francisco, capital letters and space included.

So: text for anything a human types into a search box. keyword for anything you filter on exactly, sort by, or group by.

The bug this causes

Map a field as text and query it with term, which looks for one exact term:

curl -s localhost:9200/txt-test/_search -H 'Content-Type: application/json' \
  -d '{"query": {"term": {"city": "San Francisco"}}}'
# 0 hits

Zero. The document is right there. The field name is right. The value is spelled correctly. And the query matches nothing, because the index contains san and francisco, and term is looking for the single term San Francisco, which was never stored.

Prove it by asking for a term that does exist:

-d '{"query": {"term": {"city": "san"}}}'          # 1 hit
-d '{"query": {"match": {"city": "San Francisco"}}}'  # 1 hit

match works because it analyses the query string with the same analyzer the field uses, producing san and francisco, which do match. That is the whole difference between match and term, and it is worth stating as a rule: term on a text field is almost always a bug.

Which is why you usually want both

            "city": {
                "type": "text",
                "analyzer": "stayhub_text",
                "fields": {"raw": {"type": "keyword"}},
            },

One field on the way in, two on the way out. city matches loosely, city.raw filters, sorts and aggregates exactly. Dynamic mapping does this automatically and calls the sub-field .keyword; when you write the mapping yourself you pick the name.

The one thing to avoid is doing this to every string out of habit. A field like property_type, whose values are HOUSE, CABIN and so on, is never typed into a search box — it comes from a dropdown. It should be a plain keyword, and giving it a text half builds an inverted index nothing will query.

keyword has limits worth knowing

Dynamic mapping adds "ignore_above": 256 to the keyword sub-fields it creates, and that is not decoration: a keyword field's terms go into the term dictionary whole, so indexing a 2KB description as a keyword is both useless and expensive. Values longer than the limit are skipped — stored in _source, absent from the index. Same silent-absence problem as ignore_malformed, and worth remembering when a long value mysteriously does not match.

The other thing keyword does not do is case-insensitivity. "cabin" will not match a keyword holding "Cabin". If you need exact-but-case-insensitive — email addresses, usernames, slugs — use a normalizer, which is a stripped-down analyzer for keyword fields that can lowercase without tokenising.

Numbers, and money in particular

Elasticsearch has the numeric types you would expect: byte, short, integer, long, float, double, half_float, scaled_float.

Choose by the range of values, not by habit. A max_guests field holding 1 to 50 does not need a long; integer is plenty and smaller. Contrary to the SQL instinct, the width mostly affects disk rather than query speed here, but it is free to get right.

Money is the one that needs a real decision:

            "price_per_night": {"type": "scaled_float", "scaling_factor": 100},
            "cleaning_fee": {"type": "scaled_float", "scaling_factor": 100},
...
            "bathrooms": {"type": "scaled_float", "scaling_factor": 10},
...
            "rating_average": {"type": "scaled_float", "scaling_factor": 100},

scaled_float stores the value as a long, multiplied by the scaling factor. A price of 119.99 with a factor of 100 is stored as the integer 11999. It is exact to the penny, sorts and ranges correctly, and compresses better than a double because integers do.

The alternative is double, which works and carries the usual binary floating-point surprises into your price filters. float is worse: it has about seven significant digits, so a price of 1234567.89 is already imprecise.

Note bathrooms uses a factor of 10, not 100. Half-bathrooms exist; quarter-bathrooms do not. The factor is a statement about the precision the data actually has.

Numbers that are not numbers

An identifier made of digits — a phone number, a postcode, an order reference — should be a keyword, not a numeric type. You never sum them and you never range over them; you look them up exactly. Numeric types are optimised for range queries and keyword fields for exact lookups, so the wrong choice is slower at the only thing you do.

And a numeric type will happily eat the leading zero off a postcode.

date

            "created_at": {"type": "date"},

Internally a date is a long: milliseconds since the epoch, UTC. What it accepts on the way in is more forgiving — an ISO 8601 string, a number of milliseconds, or a number of seconds if you say so:

{ "type": "date", "format": "strict_date_optional_time||epoch_millis" }

That is the default. Adding your own formats is a common need, and the strict_ prefix is worth preferring: without it, 2026-8-2 parses, and a date field that accepts sloppy input is a date field that will one day accept the wrong thing.

Two practical notes. A date with no timezone is treated as UTC, so a naive local timestamp is silently shifted — always send an offset. And date maths in queries (now-7d, now/d) is evaluated by the cluster, in the cluster's clock, which is what you want for "last 7 days" and not what you want for "since midnight in the user's timezone".

boolean

Accepts true/false and the strings "true"/ "false". The thing worth knowing is that a missing boolean is not false — it is missing, and {"term": {"deleted": false}} will not match a document where the field is absent. Use exists, or set the field explicitly at index time, which is what a well-defined document shape gets you.

geo_point

            "latitude": {"type": "double"},
            "longitude": {"type": "double"},
            "location": {"type": "geo_point"},

Note that StayHub stores the coordinates three times: two plain doubles for display, and a geo_point for querying. That is not waste. The doubles are what the result card renders; the geo_point is a different data structure entirely, and only it can answer "within 10km of here". Lesson 14 covers the queries, including the format that silently reverses latitude and longitude.

object and nested

This is the second big trap, and unlike term-on-text it produces a plausible wrong answer rather than an empty one.

A JSON object inside a document is mapped as an object by default, and its fields are flattened into dotted paths. Given reviews:

{
  "title": "Cedar Cabin",
  "reviews": [
    { "author": "ana", "rating": 5 },
    { "author": "bob", "rating": 1 }
  ]
}

What is actually indexed is:

reviews.author: ["ana", "bob"]
reviews.rating: [5, 1]

Two flat lists. The pairing is gone. So ask a question that depends on the pairing — did bob leave a 5-star review? — and:

curl -s localhost:9200/nest-test/_search -H 'Content-Type: application/json' -d '{
  "query": {"bool": {"must": [
    {"term": {"reviews.author": "bob"}},
    {"term": {"reviews.rating": 5}}
  ]}}
}'
# hits: 1

One hit. Bob gave it one star. The document matches because bob is somewhere in the authors list and 5 is somewhere in the ratings list, and nothing recorded that they came from different objects.

nested fixes it, by indexing each object in the array as its own hidden document:

{ "reviews": { "type": "nested", "properties": {
    "author": { "type": "keyword" },
    "rating":  { "type": "integer" }
} } }

Queried through a nested clause, which scopes the inner conditions to a single object:

-d '{"query": {"nested": {"path": "reviews", "query": {"bool": {"must": [
      {"term": {"reviews.author": "bob"}},
      {"term": {"reviews.rating": 5}}
]}}}}}'
# hits: 0        <- correct

# and the same query for ana:
# hits: 1        <- also correct

What nested costs

It is not free, which is why it is not the default. Each array element becomes a separate Lucene document, so a listing with 200 reviews is 201 documents on disk. Updating any part of the parent reindexes all of them. Nested fields cannot be aggregated or sorted without a nested wrapper, and there are cluster-level limits on how many nested fields and objects an index may have, precisely because it is easy to blow up an index this way.

So the rule is narrow: use nested when you need to query two fields of the same array element together. If you only ever ask "does this listing have any 5-star review", plain object is correct and cheaper.

And often the best answer is neither — do not put the array in the document at all. StayHub's amenities are a flat keyword array because a filter panel only ever asks "does this listing have wifi", never "does it have wifi at a particular price". Lesson 6 is about making that call deliberately.

Arrays are not a type

Worth stating because it surprises people: there is no array type. Every field can hold an array of its type, with no mapping change:

{ "amenities": ["wifi", "kitchen"] }
{ "amenities": "wifi" }

Both are valid against {"amenities": {"type": "keyword"}}. A query for {"term": {"amenities": "wifi"}} matches both. This is why "does this listing have wifi" needs no special handling at all — and it is also the mechanism behind the nested problem above, since flattening an array of objects is what a field holding multiple values looks like.

The specialised ones worth knowing exist

You will not need these on day one, but knowing the names saves reinventing them.

ip stores IPv4 and IPv6 and supports CIDR range queries — {"term": {"client_ip": "10.0.0.0/8"}} works. Better than a keyword the moment you want subnets.

search_as_you_type and completion are the two autocomplete answers. The first builds n-gram-ish sub-fields you query normally; the second is a separate in-memory structure that is very fast and cannot be filtered easily. Pick by whether suggestions need to respect the user's other filters.

The range types — integer_range, date_range, float_range — store a range in one field and answer "which ranges contain this point" or "which overlap this range". For availability windows and price bands this is much better than a pair of fields.

dense_vector holds an embedding for k-nearest-neighbour search, which is what "semantic search" means in practice.

flattened maps an entire object as one field of keywords, avoiding the mapping explosion you get when documents carry arbitrary user-defined keys. You lose per-field types; you gain an index that does not acquire a thousand fields.

What null means, which is not what you expect

A JSON null, an empty array, and an array of nulls are all treated the same way: the field is not indexed at all. It is not indexed as "empty" and it is not indexed as zero — it simply is not there.

{ "rating_average": null }
{ "rating_average": [] }
{ "rating_average": [null, null] }

For all three, {"exists": {"field": "rating_average"}} is false, and every range or term query on it misses the document. That is usually right, and it becomes wrong the moment somebody writes a filter like "listings rated under 3 stars" and quietly excludes every listing that has no rating yet — which is exactly the set a new host cares about.

null_value substitutes something searchable:

{ "rating_average": { "type": "scaled_float", "scaling_factor": 100, "null_value": 0 } }

Now a null arrives in the index as 0. Note carefully that _source still says null — the substitution happens on the way into the index only, so what you search and what you display disagree. That is either exactly what you want or a trap, and it depends entirely on whether the caller reads the field from _source or from the query.

The alternative, and usually the better one, is to decide the default in your application and send a real value. A document shape with no nulls in it is easier to reason about than a mapping full of null_value rules.

The types that do not exist

Three absences trip up people arriving from SQL.

There is no decimal type. scaled_float is the answer, and it is a long underneath. If you need arbitrary precision, store the string and a numeric copy for sorting.

There is no enum. A keyword plus dynamic: strict gets you the field-name safety; nothing validates the value. Elasticsearch will index "HOSUE" without complaint, and the symptom is a facet with a bucket of one. Validate before indexing — in StayHub the values come from a Python enum, and the index never sees a string the application did not produce.

There are no foreign keys and no uniqueness constraints. The only uniqueness you get is the document _id, which is why choosing your own id matters so much — lesson 7.

How many fields is too many

An index has a limit of 1,000 fields by default, and hitting it is a real production failure with a distinctive shape: indexing starts throwing Limit of total fields [1000] has been exceeded, usually at 3am, usually because some upstream system started sending an object keyed by user id or by timestamp.

Every distinct key becomes a mapping entry, and mapping entries live in the cluster state, which is held in memory on every node and replicated to all of them. So a runaway mapping is not just untidy; it degrades the whole cluster.

Raising the limit is possible and is nearly always the wrong first move. The right moves are dynamic: strict on indexes you control, so an unexpected field is an error rather than a new mapping entry, and the flattened type for the genuinely dynamic ones.

Choosing, in one pass

Ask what you will do with the field, not what it looks like.

Typed into a search box, matched loosely → text, with an analyzer you chose. Filtered exactly, sorted, or aggregated → keyword. Both → text with a keyword multi-field.

Money → scaled_float with a factor matching the real precision. Other numbers → the smallest type that fits the range. Digits that are an identifier → keyword.

A timestamp → date with a strict_ format and an explicit timezone on the way in. Coordinates you will search by → geo_point, in addition to any plain numbers you display. An array of objects whose fields you query together → nested, and only then.

Returned but never queried → whatever type, with "index": false.

Reading a mapping you did not write

You will inherit indexes. Two requests tell you most of what you need:

# the mapping, as it stands
curl -s localhost:9200/stayhub-properties/_mapping?pretty

# every field's type across every index — the fastest way to find a field that is
# a `long` in one index and a `keyword` in another, which breaks cross-index search
curl -s "localhost:9200/*/_field_caps?fields=*&pretty" | head -40

What to look for, in order. Numeric fields that should be scaled_float and are long — lesson 3's bug, already shipped. Strings that are text and only ever filtered on, which means every filter is quietly matching tokens. Strings that are keyword and are typed into a search box, which means the search only works when the user's spelling and capitalisation match exactly. And object arrays that are queried on two fields at once.

All four are silent. None of them raises an error; each returns results that are merely wrong, which is why reading the mapping is a better use of an hour than reading the query.

Get these right the first time, because lesson 3 already showed what changing one costs. The next lesson is about the other half of a text field — the analyzer, which decides what those terms actually are.