Migrating App Engine Search to a REST API Without Rewriting Your Queries

The expensive part of leaving App Engine's Search API is usually redesigning your search — picking an analyzer, remodeling documents, relearning a query DSL. Migrating to altengine skips that: the query language, field types, facets, sorting and document schemas are preserved, so the strings you already send stay the strings you send. What you rewrite is the call layer — the SDK objects become HTTP requests. That's feature parity, not drop-in, and the distinction is the whole point of this post.

What carries over unchanged

These are the parts of your search that took real design effort, and none of them move:

  • Query strings. genre:comedy rating > 3 is still genre:comedy rating > 3. Implicit AND between terms, field scoping, OR / NOT / -, grouping like genre:(comedy OR drama), quoted phrases, ~stemming, numeric and date comparisons, and distance(loc, geopoint(37.7, -122.4)) < 1000 all parse the same way.
  • Field types. text, html, atom, number, date and geo mean what they meant. (There are two additions — tokenprefix and untokenprefix for autocomplete — but nothing you were using went away.)
  • Document shape. A string id, an optional numeric rank, an optional lang, multi-valued dynamic fields, and separate facets. Two documents in one index can carry different fields, same as before. Even the rank default is the App Engine one: seconds since 2011-01-01.
  • Facets. Discovery, explicit facet lists, and refinements. Atom facets return value counts; number facets return half-open [min, max) ranges.
  • Namespaces and indexes. Your namespace/index split becomes path segments instead of constructor arguments.

What actually changes

You lose the language SDK. There is no search.Index object, no search.Document constructor, no library-side batching or retry helper. Every operation is an HTTP request to https://api.altengine.net with an organization API key as a bearer token:

Authorization: Bearer ae_yourkeyid.your-api-key-secret

Paths are scoped to an instance you provision in the console, then a namespace, then an index — /v1/search/{instance}/ns/{namespace}/idx/{index}/…. Use _default where you used the default namespace; it can't be an empty path segment. Reads need a read grant, writes a write grant, and deletes a full grant.

So the honest framing: this is a drop-in-style, feature-parity migration. Your search design transfers; your plumbing does not.

Before and after: indexing a document

The App Engine version, with the SDK building typed field objects:

index = search.Index(name='films')
index.put(search.Document(
    doc_id='f1',
    rank=12345,
    fields=[
        search.TextField(name='title', value='Up in the Air'),
        search.AtomField(name='genre', value='drama'),
        search.NumberField(name='rating', value=4),
    ],
    facets=[search.AtomFacet(name='genre', value='drama')]))

The same document over REST. The typed constructors become a type string on each field — that's the entire translation:

POST /v1/search/catalog/ns/_default/idx/films/documents

{
  "documents": [
    {
      "id": "f1",
      "rank": 12345,
      "fields": [
        { "name": "title",  "type": "text",   "value": "Up in the Air" },
        { "name": "genre",  "type": "atom",   "value": "drama" },
        { "name": "rating", "type": "number", "value": 4 }
      ],
      "facets": [
        { "name": "genre", "type": "atom", "value": "drama" }
      ]
    }
  ]
}

# → { "ids": ["f1"] }

Puts are batched up to 200 documents per request and are idempotent by id, so a retry after a timeout is safe. The index is created on first write — there's no provisioning step per index.

Before and after: running a query

App Engine's QueryOptions object:

results = index.search(search.Query(
    query_string='genre:comedy rating > 3',
    options=search.QueryOptions(
        limit=20,
        returned_fields=['title', 'rating'],
        sort_options=search.SortOptions(expressions=[
            search.SortExpression(
                expression='rating',
                direction=search.SortExpression.DESCENDING,
                default_value=0)])),
    enable_facet_discovery=True,
    facet_refinements=[search.FacetRefinement('genre', 'scifi')]))

The same query as a JSON body. Note the query string itself is copied across character for character:

POST /v1/search/catalog/ns/_default/idx/films/search

{
  "query": "genre:comedy rating > 3",
  "limit": 20,
  "returned_fields": ["title", "rating"],
  "sort": [{ "expr": "rating", "desc": true, "default": 0 }],
  "facet_discover": 5,
  "facet_refinements": [{ "name": "genre", "value": "scifi" }],
  "total_hits_accuracy": 1000
}

The response carries total_hits, total_hits_exact, returned, a results array of { id, rank, score, document }, a cursor, and facets with per-value counts. Full shapes are in the Search API reference.

The concept mapping, in one table

App Engine Search concepts and their altengine request equivalents
App Enginealtengine
query_stringquery — same syntax, verbatim
limit / offsetlimit (default 20, max 1000) / offset (max 1000)
ids_onlyids_only
returned_fieldsreturned_fields
SortExpressionsort: [{ expr, desc, default }]
Match scorerscorer: "match" (BM25); sort on _score
Number-found accuracytotal_hits_accuracy (default 20, max 10000)
Facet discoveryfacet_discover: N, or an explicit facets list
FacetRefinementfacet_refinements: [{ name, value }]
Cursor pagingResponse cursor, passed back as cursor
Snippet expressionsA snippet block; excerpts arrive per result

The rough edges worth knowing before you start

Parity of semantics doesn't mean identical ergonomics. Four things to plan for:

  • Snippets moved from expressions to a block. Instead of a returned expression that calls snippet(...), you send a snippet object naming fields, max_tokens (default 32, clamped 1–64) and your own pre_tag/post_tag; each result comes back with a snippet map of field name to excerpt. Only tokenized fields (text, html, tokenprefix) are snippetable.
  • Paging is one opaque cursor per response. A cursor is present whenever more pages remain, so you follow it until it's absent rather than deriving positions from a per-result cursor. Pagination is independent of the hit count — when total_hits_exact is false, render the count as "N+".
  • Stemming is instance configuration, and it's off by default. If your queries lean on ~term, turn stemming on in the console before you backfill — it applies to documents written from then on, so anything indexed earlier needs a re-put. Getting this order wrong is the most common way a migration looks broken.
  • Rate limits are per instance. Search is capped at a sustained 100 requests per second per instance by default; over it you get 429 with a RATE_LIMITED code. Backfills throttle separately — fill each request to 200 documents, keep roughly 6–8 in flight per index for about 2,000 documents/second, and honor Retry-After.

On the other side of the ledger, a few things exist here that App Engine never had: instance-level synonym dictionaries that need no reindex, query rules for pinning and hiding documents, and field collapsing for "one result per brand". None are required to migrate — leave them alone until the port is green.

A migration order that de-risks the cutover

  1. Inventory. List your indexes, their namespaces, and the distinct query strings your app emits. That list is your test suite.
  2. Provision and key. Create a search instance in the console and mint one key with a write grant for the backfill, separate from the read key your app will use.
  3. Port the write path first. Translate your document builder into the JSON field shape. Backfill into a real index and check GET …/idx/{index}/schema — the union schema is the fastest way to catch a field that got the wrong type.
  4. Port the read path. Move your query strings across untouched and map the options using the table above.
  5. Dual-run and diff. Send production queries to both systems and compare ids and counts. Ordering differences almost always trace back to rank or a missing sort, since the default order is rank descending.
  6. Cut over. Keep writing to both until you're satisfied, then stop. Dropping an index is free, so a discarded trial run costs you only the storage it used while it existed.

What it costs while you're doing this

There's no cluster to provision, so a trial index costs its storage and nothing else. Search bills on four axes: queries at a flat $0.50 per 10,000 requests; reads at $0.10 per 100,000 (one read = up to 100 rows examined, and a repeated identical query served from cache bills zero reads — just its query); writes at $0.25 per 100,000 rows written, where a document put writes its own row plus its index rows; and stored data at $0.80 per GB-month for documents plus their index.

The practical consequence for a migration: the backfill is your main cost, it's proportional to how many fields you index, and it's one-time. A dual-run period adds queries but few reads, because repeated identical queries hit the cache. And the first $3.00 of usage each month is free for every organization, with no card required to start — most ports finish inside it. Full rates are on the pricing page.

The summary

If your App Engine Search code is a document builder, a query-string builder, and a results loop, you're rewriting the first and third and keeping the second. The search design — the part that took judgment — comes with you.

Start free in the console

← Back to the blog