Guide

Designing a Real Estate Search Engine with Elasticsearch

Designing listing search on Elasticsearch: mappings, filters, geo queries, autocomplete, pagination, facet counts, relevance and zero-downtime reindexing.

Cover: a search box with filters above an alias pointing at a live index while a new one reindexes.

The problem: one search box, five kinds of question

A buyer on a listing site asks five different things through the same screen. Which homes match these filters. Which of them are inside this map view. What was that address I started typing. How many condos are left if I add a price cap. And, occasionally, which listings mention a fenced yard. A relational or document database answers the first well, the second with a spatial index, and the rest with increasing effort, which is when teams add a search engine as a second, derived copy of the listing data.

Whether you need one at all is covered in our tech stack guide, and how listings reach your database in the MLS data pipeline architecture guide. This article starts after both decisions: you have chosen Elasticsearch, the listings are in a system of record, and the question is how to design the index, the queries and the sync. Each capability and limit is linked to Elastic's documentation as read on October 12 and 13, 2026.

Where the index sits

Search architecture: database, indexer, versioned indices and an aliasThe primary database sends changed listings to an indexer. The indexer writes bulk upserts and deletes to the live index, listings_v7, and a full load plus the same changes to a new index, listings_v8, while it is being built. The website calls a search API, and the search API queries only the alias named listings. The alias points to listings_v7 now and is moved to listings_v8 in one atomic step after the new index is verified.WRITE PATHREAD PATHPrimary databasesystem of recorddisplayable listingsIndexerone document per listingsends bulk requestschanged rowslistings_v8new mapping, being builtnot searched yetlistings_v7live indexserves every searchfull load, thenthe same changesbulk upsertsand deletesWebsite and appssearch box, filters,map, suggestionsSearch APIbuilds the query, addsdisplay filtersrequestALIASlistingsthe only name the API knowsqueriespoints to nowkept for rollbackatomicswap
The index is a derived copy. The indexer writes to versioned indices; the application reads through one alias. Index names are illustrative.

The same architecture in words:

  • Write path. When rows change in the primary database, an indexer builds one search document per listing and sends bulk upserts and deletes to the live index (here listings_v7).
  • Rebuild. When the mapping changes, the indexer fills a new index (listings_v8) with a full load plus the same ongoing changes. Nothing searches it yet.
  • Read path. The website and apps call a search API, which builds the query, adds the display filters, and queries only the alias listings.
  • Swap. After the new index is verified, the alias moves from listings_v7 to listings_v8 in one atomic step, and the old index is kept for rollback.

Two rules follow: the application never names a physical index, and the index never holds anything that cannot be rebuilt from the database.

Index design: one document per listing

Start with explicit mappings. Elasticsearch will guess types if you let it, but with "dynamic": "strict", when new fields are detected an exception is thrown and the document is rejected. For MLS data, where a feed can add a field without notice, we would take a rejected document that raises an alert over a silently mis-typed field, because you cannot change the field type of an existing field; the fix is a new index and a reindex.

The type you choose for each field decides which operations are possible on it:

Listing dataTypeWhy
Status, property type, city, postal code, feature tags, listing IDkeywordMeant for structured content such as IDs, email addresses, hostnames, status codes, zip codes, or tags, and usable for sorting, aggregations, or scripting. Elastic also notes that term query searches on keyword fields are often faster than term searches on numeric fields, so an MLS number you never range over is a keyword even when it is all digits.
Public remarkstextAnalyzed, to search for individual words within each full text field. Text fields are not used for sorting and seldom used for aggregations.
Price, bedrooms, bathrooms, living area, lot size, year builtlong, short, half_float, integerNumeric fields are optimized for range queries, which is what a price or bedroom filter is. For integers, pick the smallest type which is enough for your use-case.
List date, modification time, open house startdateStored internally as a long number representing milliseconds-since-the-epoch, in UTC. Send dates with their offset.
The listing's coordinatesgeo_pointSupports finding points within a bounding box, within a certain distance of a central point, or within a geo_shape query, plus grid aggregations and sorting documents by distance.
Neighborhood, school zone or custom area boundariesgeo_shapeRequired if the data being indexed contains shapes other than just points. Boundaries belong in their own small index, not on the listing.

One field often needs two types: a city name is filtered and counted as a keyword but also typed into a search box. Multi-fields exist for this, since it is often useful to index the same field in different ways for different purposes. For keyword fields that users type, add a normalizer, which is similar to analyzer except that it guarantees that the analysis chain produces a single token, so that "AUSTIN" from one feed and "Austin" from another land in the same bucket.

Arrays of objects: nested, flattened, or neither

Rooms and open houses arrive as arrays of objects, and this is where a listing mapping usually goes wrong. Elasticsearch has no concept of inner objects: it flattens an array of objects into multi-value fields, and the association between the values of one object is lost. A listing with a Saturday open house at 10:00 and a Sunday one at 14:00 would then match a search for "Saturday at 14:00".

An illustrative mapping

Illustrative: field names, analyzers and settings are examples, trimmed to the fields discussed here.

PUT /listings_v8
{
  "settings": {
    "refresh_interval": "30s",
    "analysis": {
      "normalizer": {
        "lower_ascii": { "type": "custom", "filter": ["lowercase", "asciifolding"] }
      }
    }
  },
  "mappings": {
    "dynamic": "strict",
    "properties": {
      "listing_key":     { "type": "keyword" },
      "source_system":   { "type": "keyword" },
      "standard_status": { "type": "keyword" },
      "property_type":   { "type": "keyword" },
      "city":            { "type": "keyword", "normalizer": "lower_ascii" },
      "postal_code":     { "type": "keyword" },
      "features":        { "type": "keyword" },
      "list_price":      { "type": "long" },
      "bedrooms":        { "type": "short" },
      "bathrooms":       { "type": "half_float" },
      "living_area":     { "type": "integer" },
      "listed_at":       { "type": "date" },
      "modified_at":     { "type": "date" },
      "next_open_house": { "type": "date" },
      "location":        { "type": "geo_point" },
      "address":         { "type": "search_as_you_type" },
      "address_exact":   { "type": "keyword", "normalizer": "lower_ascii" },
      "remarks":         { "type": "text", "analyzer": "english" },
      "open_houses": {
        "type": "nested",
        "properties": {
          "kind":      { "type": "keyword" },
          "starts_at": { "type": "date" },
          "ends_at":   { "type": "date" }
        }
      },
      "local_fields":    { "type": "flattened" }
    }
  }
}

Filters versus queries, and why filters cache

Every clause in a search runs in one of two contexts. In query context a clause answers how well does this document match and produces a score. In filter context the answer is simply "yes" or "no". Elastic lists the consequences: filters skip score calculation, and Elasticsearch automatically caches frequently used filters. In a bool query, clauses under filter and must_not run in filter context, meaning that scoring is ignored and clauses are considered for caching.

Almost everything in a property search is a filter: status, price range, bedrooms, property type and the map bounds are yes-or-no conditions. Only free text belongs in query context. A search with no text should have an empty must and an explicit sort.

The caching has edges worth knowing, because they shape how you write filters:

So make filters repeatable. A viewport snapped to a tile grid and a price range chosen from fixed steps produce the same clause for many users; a raw bounding box with fourteen decimal places is unique to one mouse movement.

Geospatial search

Three shapes cover what buyers do on a map. The viewport is a geo_bounding_box, which matches geo_point and geo_shape values that intersect a bounding box. A radius ("within 5 miles of this school") is geo_distance, which matches geo_point and geo_shape values within a given distance of a geopoint. A drawn area or a neighborhood boundary is the geo_shape query, which filters documents indexed using either the geo_shape or the geo_point type by a spatial relationship: intersects, contains, within, or disjoint. The older geo_polygon query is deprecated in 7.12; new code should not use it.

For named areas, do not send the boundary with every request. The geo_shape query accepts a shape which has already been indexed in another index, so a neighborhood search references the boundary by ID. For stable boundaries we would also weigh resolving them at index time and storing neighborhood IDs on the listing as keywords: a spatial test becomes a term filter, at the cost of reindexing listings when a boundary changes. Viewport behavior, clustering and drawn areas are covered in building map-based property search.

Autocomplete: two tools for two jobs

The search box suggests two kinds of thing. Places (cities, neighborhoods, postal codes, schools) form a small, slowly changing list typed from the beginning. Addresses are a large, constantly changing set typed in any order: "main 123", "123 n main".

ApproachWhat the documentation saysFits
Completion suggesterOptimized for speed, with data structures that are costly to build and are stored in-memory. It considers all documents in the index unless you add context mappings. Elastic recommends it for text which has a widely known order.A dedicated places index: every entry should be suggested, and names are typed from the start.
search_as_you_type fieldCreates shingle and prefix subfields; supports both prefix completion and infix completion. Queried with a multi_match of type bool_prefix, which can match the query terms in any order, but will score documents higher if they contain the terms in order.Addresses on the listing index. It is an ordinary query, so status and display filters apply.
Custom edge n-gramsUseful for search-as-you-type queries, with the advantage when trying to autocomplete words that can appear in any order. With an index analyzer, search terms longer than the max_gram length may not match any indexed terms.When you need control over tokenization that the built-in field type does not give.

The second row matters most for listings. Because search_as_you_type is queried like any other field, the filter that hides a listing from results also hides it from suggestions. The completion suggester needs separate handling for that, which is one reason we would keep it for places. A suggestion for a listing you no longer display is more than a dead link: whether it may appear at all is a question for your MLS rules.

Sorting and pagination

Sort on keyword, numeric and date fields. Elastic's guidance is to avoid sorting by text fields. Three details prevent the usual bugs:

Pagination has a hard default. With from and size, you cannot page through more than 10,000 hits; the cap is index.max_result_window, and it exists because search requests take heap memory and time proportional to from + size. We would not raise it. Three patterns cover a listing site:

A map does not paginate. When thousands of listings fall inside the viewport, return clusters from a grid aggregation and a short list.

Facet counts with aggregations

The numbers beside each filter option ("Condo (212)") come from aggregations run with the search. A terms aggregation on a keyword field returns, by default, the top ten terms with the most documents; set size for longer lists such as cities. A range aggregation gives price bands, and it includes the from value and excludes the to value for each range, so bands do not double count at their edges.

The design question is what a count should mean once the user has selected something in that same facet. If the buyer ticks "Condo" and that filter is applied to everything, every other type shows zero and the facet is useless for adding a second type. Elasticsearch separates the two scopes: boolean filters apply to both search hits and aggregations, while a post_filter applies only to search hits, not aggregations. So filters that should narrow every count go in the bool filter, and a multi-select facet's own selection goes in post_filter, with each other facet's aggregation wrapped in a filter aggregation that re-applies the selections it should respect.

Ask only for the facets on screen, and consider sending counts as a separate size: 0 request, which is the form the shard request cache stores.

An illustrative query

Illustrative: a text search for "fenced yard" among active three-bedroom homes in a viewport, with a multi-select property type, newest first, continuing after the previous page. Values are examples.

POST /listings/_search
{
  "size": 24,
  "query": {
    "bool": {
      "must": [
        { "match": { "remarks": { "query": "fenced yard", "operator": "and" } } }
      ],
      "filter": [
        { "term":  { "standard_status": "Active" } },
        { "range": { "list_price": { "gte": 400000, "lte": 650000 } } },
        { "range": { "bedrooms":   { "gte": 3 } } },
        { "geo_bounding_box": {
            "location": {
              "top_left":     { "lat": 30.42, "lon": -97.90 },
              "bottom_right": { "lat": 30.18, "lon": -97.62 }
            }
        } }
      ]
    }
  },
  "post_filter": { "terms": { "property_type": ["Condominium", "Townhouse"] } },
  "aggs": {
    "property_types": { "terms": { "field": "property_type", "size": 20 } }
  },
  "sort": [
    { "listed_at":   "desc" },
    { "listing_key": "asc" }
  ],
  "search_after": [1791763200000, "ACT-1048812"]
}

Relevance for addresses and free text

Most listing searches have no text and so no relevance problem. Where text appears, it is one of two different things. Addresses are identifiers that people type inconsistently: "123 N Main St #4" and "123 north main street apt 4" are the same place. What we would build:

Remarks are prose. Use a language analyzer, require all terms for short queries, and weight a headline field where one exists: individual fields can be boosted with the caret (^) notation. If a text search should still favor fresh inventory, a decay function scores a document by the distance of a numeric field value of the document from a user given origin, for example the list date from today. Keep such a boost mild and drop it when the user has chosen a sort.

Keeping the index in sync with the database

Aliases and zero-downtime reindexing

Mappings change: a new filter, a different analyzer, a field that should have been a keyword. Since field types cannot be changed in place, every such change is a new index. Elastic lists reindexing data without downtime as a purpose of aliases, and the aliases API can perform multiple actions in a single atomic operation, so that during the swap the alias has no downtime and never points to both at the same time. The procedure we would follow:

  1. Create the next versioned index with the new mapping.
  2. Start sending live changes to both the current and the new index.
  3. Fill the new index from the database, not from the old index, so that the rebuild also proves the index is rebuildable. With external versions (below), the backfill cannot overwrite a newer live change.
  4. Verify: document count against displayable rows in the database, and a fixed set of saved queries run against both indices.
  5. Swap the alias in one request. Keep the old index until you are sure, then delete it.

For the load itself, bulk requests will yield much better performance than single-document index requests; the batch size is found by testing, and it is advisable to avoid going beyond a couple tens of megabytes per request. During a large load you can disable refreshing by setting the refresh interval to -1 and set index.number_of_replicas to 0, which Elastic pairs with a condition a derived index meets: it is important that the data lives elsewhere. Restore both before the swap. If the cluster answers TOO_MANY_REQUESTS (429), pause and retry with randomized exponential backoff.

Incremental updates and the refresh interval

Between rebuilds, each batch of changed listings becomes one bulk request of index and delete actions. A bulk request can perform multiple index, create, delete, and update actions in a single request, and it can partly fail: the response carries an errors flag that is true when one or more of the operations in the bulk request did not complete successfully, with a result for each item. An indexer that checks only the HTTP status will lose documents quietly.

Changes are not searchable at once. By default Elasticsearch periodically refreshes indices every second, but only on indices that have received one search request or more in the last 30 seconds. A listing index fed by a sync that runs every few minutes has little use for one-second refreshes, and Elastic notes that raising index.refresh_interval, e.g. 30s, might help improve indexing speed. A longer interval also keeps cached facet responses valid for longer.

Out-of-order writes are the subtle failure. Two workers process two changes to the same listing and the older one lands last. With external versioning, a document is indexed only if the specified version is strictly higher than the version of the stored document. Using the listing's modification timestamp as that version turns a late, stale write into a version conflict you can ignore. Deletes need the same care: send the version with the delete, and note that index.gc_deletes, the time a deleted document's version number remains available for further versioned operations, defaults to 60 seconds. A stale write that arrives later than that can bring a removed listing back, which is one job of the reconciliation below.

Removals

A listing that is sold, withdrawn or no longer licensed for display must leave search results. We would handle that in three layers. The indexer turns "no longer displayable" into a delete action in the same bulk request as the updates. The search API adds the status and display filters to every query, so a document that lingers is still not returned. And a scheduled reconciliation compares listing keys in the index against displayable keys in the database and deletes the difference. How long a removed listing may remain visible is set by your MLS license; the pipeline guide covers those rules.

What to watch in production

SignalWhat it tells you
Index document count against displayable rows in the databaseMissed updates or missed removals
Age of the newest modified_at in the index against the databaseIndexing lag
Bulk item failures and rejected documents (strict mapping)A feed added or changed a field
429 responses to bulk requestsIndexing faster than the cluster can absorb
Search latency by request type: list, facets, map, suggestionsWhich query shape is slow, instead of one blended average
Searches that return zero results, with their textMissing synonyms and address forms
Requests rejected at the 10,000-hit windowA client paging with from where it should use search_after

Peregrine's real estate SaaS case study describes this separation of roles: MongoDB as the source of truth and Elasticsearch handling property search, filters, geo-queries and auto-suggestions, in place of direct database queries that were taking 3 to 5 seconds under load. The case study reports search response time going from 3.5 seconds to 180 milliseconds. That is one platform's result as published there, not a figure to expect elsewhere.

A saved search is a stored query; that design is covered in saved searches and listing alerts, and the field mapping that feeds this index in MLS data normalization.

Frequently Asked Questions

Should Elasticsearch be the primary database for listings?

We recommend against it. Treat the index as a derived copy that can be rebuilt from the system of record. Elastic’s own guidance for fast initial loads with no replicas depends on the fact that the data lives elsewhere, and a mapping change requires a new index because you cannot change the field type of an existing field.

How deep can users page through search results?

With from and size, you cannot page through more than 10,000 hits by default. Beyond that, use search_after with a unique tiebreaker in the sort, and a point in time when the pages must be consistent with each other.

Completion suggester or search-as-you-type for the search box?

Both, for different lists. The completion suggester is optimized for speed and suits a small index of place names. Addresses fit a search_as_you_type field, which matches the query terms in any order and is filtered like any other query, so listings you may not display stay out of suggestions.

Capabilities, defaults and limits were read from the linked Elastic documentation on October 12 and 13, 2026 and can change between versions. The mapping and the query are illustrative, were not run against a cluster, and are not a drop-in configuration. OpenSearch was not checked.

Building or repairing listing search? See our MLS and IDX integration service. Every project starts with a 30-minute technical discovery call with an engineer.

About the examples. Every code sample here is illustrative. The mapping and the query were run against Elasticsearch 8.19.1 on October 13, 2026 and were accepted. They were not load-tested, and OpenSearch was not checked.

Technical consultation

Designing or Repairing Listing Search?

Tell us your listing volume, the filters and map features you need, and where search is slow or wrong today. An engineer will walk through the index and sync design with you.

  • A 30-minute technical discovery call with an engineer, not a salesperson.
  • Fixed-scope projects, monthly retainers, or a combination, agreed after the call.
  • Small, well-defined tasks get a scoped estimate. We reply within 1 business day.

Prefer to pick a time? Book a call on Calendly

Tell us about your project

We'll review your details and reach out within 1 business day.

Book Strategy CallCase Studies