Stale Flags
A flag that has served everyone the same answer for weeks, or that nothing has asked about, is usually ready to come out of your code. The flag list marks such a flag as stale and says why. It is a suggestion: listing changes no flag, and nothing is archived or turned off for you. The definition is beta: its conditions and threshold may change; the fields will not.
The examples run against the Quick Start
stack, in one terminal, in order: later steps use the $TOKEN, $KEY and
$FLAG_ID an earlier one sets.
When a flag is stale
N is 30 days by default; STALE_FLAG_DAYS sets it, from 1 to 3650, for the
whole installation; a change needs a restart.
A flag is stale when it was created at least N days ago, is not archived, is not
tagged permanent, has no active rollout schedule, and at least one of these
reasons applies:
| Reason | When |
|---|---|
not_evaluated |
No evaluation of the flag was recorded in the last N days, while evaluations of other flags were. |
on_for_everyone |
The flag is on at 100% with no targeting rules, and unchanged for N days. |
off_for_everyone |
The flag is off, or on at 0% with no targeting rules, and unchanged for N days. |
stale_reasons lists every reason that applies, in that order. A flag created
or last changed exactly N days ago is old enough, and an evaluation exactly N
days ago still counts.
Targeting rules that select nobody, such as a rule group with no conditions, are no rules; rules that cannot be read count as rules. A flag at any other percentage serves different users different answers, so neither of the last two reasons applies to it.
An archived flag is never stale, and neither is a flag whose rollout schedule is active, because its next stage will change it. A schedule that is paused, a draft, completed or cancelled does not keep a flag off the list.
What counts as an evaluation
Every evaluation the platform records counts:
- each answer the server gives to
GETorPOST /api/v1/feature-flags/evaluate/{flag_key}, including the MCP server'sevaluate_feature_flag; GET /api/v1/feature-flags/user/{user_id}, which answers every active flag for one user, and so counts as an evaluation of every active flag;getAllFlags(JavaScript, React Native, the edge SDK) andget_all_flags(Python) call it;- the counts the JavaScript and Python SDKs report from local evaluation, every 60 seconds and on
close(); in local evaluation,getAllFlagsandget_all_flagscount every flag they answer.
So one caller that fetches every flag keeps every active flag from being marked
not_evaluated. The edge SDK's Vercel middleware fetches every flag on each
request unless flagKeys is set: set flagKeys to the flags your requests
need.
Not evaluated means no evaluation was recorded, not that no code uses the flag.
A flag read only by a job that runs less often than every N days, a quarterly
report for example, is marked not_evaluated between runs.
When the installation recorded no flag evaluation at all in the last N days,
not_evaluated is not reported for any flag, and the list says
"evaluations_seen": false. A new installation, or one whose applications do
not reach it, sees this.
What unchanged means
Unchanged means no setting of the flag changed: an edit, including a rename, or a status change, a rollout step or a safety rollback restarts the count.
The count starts from the flag's updated_at. The request for this feature
(#1808) noted that updated_at is not the time the flag's answer last changed,
and the platform does not record that time. It is used anyway: every change to a
flag sets it, so a flag marked unchanged has not changed at all in N days. The
cost is that a change that leaves the answer as it was, a rename for example,
also restarts the count.
Keeping a flag
A flag tagged permanent is never stale. The tag must be exactly permanent;
case and surrounding spaces are ignored.
Tags are set through the API, and a PUT replaces the whole list, so send the
flag's tags with permanent added. The first block signs in and creates a flag
tagged checkout for the example; the second adds permanent and prints the
tags:
TOKEN=$(curl -s -X POST localhost:8000/api/v1/auth/login \
-H 'content-type: application/json' \
-d '{"email":"admin@demo.com","password":"Demo1234!"}' | jq -r .access_token)
FLAG_ID=$(curl -s -X POST localhost:8000/api/v1/feature-flags/ \
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"key":"stale-flags-example","name":"Stale flags example","tags":["checkout"]}' \
| jq -r .id)
curl -s "localhost:8000/api/v1/feature-flags/$FLAG_ID" \
-H "Authorization: Bearer $TOKEN" | jq -c .tags
TAGS=$(curl -s "localhost:8000/api/v1/feature-flags/$FLAG_ID" \
-H "Authorization: Bearer $TOKEN" | jq -c '(.tags // []) + ["permanent"] | unique')
curl -s -X PUT "localhost:8000/api/v1/feature-flags/$FLAG_ID" \
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d "{\"tags\": $TAGS}" | jq -c .tags
Listing the stale flags
GET /api/v1/feature-flags/?stale=true lists only the stale flags, and
stale=false the others; status and search narrow either. With the filter,
total counts the flags that match it, and skip and limit page them in the
list's order. Every item, with the filter or without it, carries stale and
stale_reasons, and the answer carries stale_days and evaluations_seen.
A sign-in can read it, and so can an API key that carries the mcp:read scope.
This creates such a key and lists the stale flags with it:
KEY=$(curl -s -X POST localhost:8000/api/v1/api-keys \
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"name":"stale-flags","scopes":["mcp:read"]}' | jq -r .key)
curl -s "localhost:8000/api/v1/feature-flags/?stale=true" -H "X-API-Key: $KEY" \
| jq -c '{stale_days, total, keys: [.items[].key]}'
Listing changes no flag. The MCP server's list_feature_flags takes the same
filter: see The MCP Server.
Next steps
Remove the flag from your code, then archive it with Archive on the flag's
page, or with POST /api/v1/feature-flags/bulk-toggle and "action": "archive".
An archived flag answers every evaluation
as off, and POST /api/v1/feature-flags/{flag_id}/unarchive makes it inactive
again. See Archived flags.