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 hitsZero. 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 hitmatch 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: 1One 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 correctWhat 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 -40What 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.