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 > 3is stillgenre:comedy rating > 3. Implicit AND between terms, field scoping,OR/NOT/-, grouping likegenre:(comedy OR drama), quoted phrases,~stemming, numeric and date comparisons, anddistance(loc, geopoint(37.7, -122.4)) < 1000all parse the same way. - Field types.
text,html,atom,number,dateandgeomean what they meant. (There are two additions —tokenprefixanduntokenprefixfor autocomplete — but nothing you were using went away.) - Document shape. A string
id, an optional numericrank, an optionallang, multi-valued dynamic fields, and separatefacets. 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-secretPaths 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 | altengine |
|---|---|
query_string | query — same syntax, verbatim |
limit / offset | limit (default 20, max 1000) / offset (max 1000) |
ids_only | ids_only |
returned_fields | returned_fields |
SortExpression | sort: [{ expr, desc, default }] |
| Match scorer | scorer: "match" (BM25); sort on _score |
| Number-found accuracy | total_hits_accuracy (default 20, max 10000) |
| Facet discovery | facet_discover: N, or an explicit facets list |
FacetRefinement | facet_refinements: [{ name, value }] |
| Cursor paging | Response cursor, passed back as cursor |
| Snippet expressions | A 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 asnippetobject namingfields,max_tokens(default 32, clamped 1–64) and your ownpre_tag/post_tag; each result comes back with asnippetmap of field name to excerpt. Only tokenized fields (text,html,tokenprefix) are snippetable. - Paging is one opaque cursor per response. A
cursoris 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 — whentotal_hits_exactisfalse, 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
429with aRATE_LIMITEDcode. Backfills throttle separately — fill each request to 200 documents, keep roughly 6–8 in flight per index for about 2,000 documents/second, and honorRetry-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
- Inventory. List your indexes, their namespaces, and the distinct query strings your app emits. That list is your test suite.
- Provision and key. Create a search instance in the console and mint one key with a
writegrant for the backfill, separate from thereadkey your app will use. - 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 wrongtype. - Port the read path. Move your query strings across untouched and map the options using the table above.
- Dual-run and diff. Send production queries to both systems and compare ids and counts. Ordering differences almost always trace back to
rankor a missingsort, since the default order isrankdescending. - 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.
← Back to the blog