> ## Documentation Index
> Fetch the complete documentation index at: https://api.simkl.org/llms.txt
> Use this file to discover all available pages before exploring further.

# Null and missing values

> Five different things a missing value can mean in the Simkl API, and how to handle each one.

`null` in a Simkl response is never noise — it's one of five specific signals. This page is the master reference; schema descriptions link back here by type.

<CardGroup cols={5}>
  <Card title="Type 1" icon="hourglass-half" href="#type-1">
    **Never happened yet** — event hasn't fired
  </Card>

  <Card title="Type 2" icon="ban" href="#type-2">
    **Doesn't apply** — key omitted entirely
  </Card>

  <Card title="Type 3" icon="folder-open" href="#type-3">
    **Empty result** — whole response null
  </Card>

  <Card title="Type 4" icon="circle-question" href="#type-4">
    **Unknown** — data not on file
  </Card>

  <Card title="Type 5" icon="flag-checkered" href="#type-5">
    **End state** — journey complete
  </Card>
</CardGroup>

<a id="type-1" />

## Type 1 — Never happened yet

<Tip>
  **Field present, value `null`** — the event hasn't fired for this user yet, but the field is a normal part of the model. Once it happens, the value flips to a real value and never reverts to `null`.
</Tip>

**Where you'll see it**

| Endpoint | Field | When it's null |
| - | - | - |
| [`GET /sync/activities`](/api-reference/simkl/get-activities) | every bucket timestamp | User has never had activity in that bucket |
| [`GET /sync/all-items`](/api-reference/simkl/get-all-items) | `last_watched_at` | Plantowatch items — user hasn't watched yet |
| [`GET /sync/all-items`](/api-reference/simkl/get-all-items) | `user_rated_at`, `user_rating` | Items the user hasn't rated |
| [`GET /sync/all-items`](/api-reference/simkl/get-all-items) | `last_watched` | Shows the user hasn't started |

**Handling**

<CodeGroup>
  ```js JavaScript {2,3} theme={"theme":{"light":"github-light","dark":"vesper"}}
  // ✓ Correct
  if (item.last_watched_at !== null) {
    showLastWatched(item.last_watched_at);
  }

  // ✗ Wrong — gives a "watched on Jan 1 1970" UI
  showLastWatched(item.last_watched_at ?? '1970-01-01T00:00:00Z');
  ```

  ```python Python {2,3} theme={"theme":{"light":"github-light","dark":"vesper"}}
  # ✓ Correct
  if item.get('last_watched_at') is not None:
      show_last_watched(item['last_watched_at'])

  # ✗ Wrong
  show_last_watched(item.get('last_watched_at') or '1970-01-01')
  ```
</CodeGroup>

<a id="type-2" />

## Type 2 — Doesn't apply to this type

<Warning>
  **Key omitted entirely** — not set to `null`, the property doesn't exist on this object. Iterating keys won't return it; `'fieldName' in obj` returns `false`.
</Warning>

**Where you'll see it**

| Endpoint | Type | What's omitted |
| - | - | - |
| [`GET /sync/activities`](/api-reference/simkl/get-activities) | `movies` block | `watching` and `hold` keys (movies can't be in those statuses) |
| [`GET /sync/all-items`](/api-reference/simkl/get-all-items) | Movie records | Episode-related fields (`seasons`, `last_watched`, etc.) |
| Movie / TV records | — | Anime-only IDs (`mal`, `anidb`, `anilist`, `kitsu`) and `anime_type` |
| [`GET /tv/episodes/{id}`](/api-reference/simkl/get-tv-episodes) | TV specials | `season` AND `episode` both omitted on `type: "special"` items |
| [`GET /anime/episodes/{id}`](/api-reference/simkl/get-anime-episodes) | Every anime episode | `season` omitted (AniDB numbers anime sequentially, no per-season concept) |
| [`GET /anime/{id}`](/api-reference/simkl/get-anime) | TV-classified shows | `relations` array omitted entirely (anime catalog only — TV records skip this field) |
| [`GET /sync/all-items`](/api-reference/simkl/get-all-items) without `?memos=yes` | every entry | `memo` key omitted (gated on the query param) |
| [`GET /sync/all-items`](/api-reference/simkl/get-all-items) without `?next_watch_info=yes` | every entry | `next_to_watch_info` key omitted |
| [`GET /sync/all-items`](/api-reference/simkl/get-all-items) without `?allow_rewatch=yes` | rewatch-session rows | `is_rewatch`, `rewatch_id`, `rewatch_status` keys omitted entirely; only the canonical entry returns |
| [`GET /sync/all-items`](/api-reference/simkl/get-all-items) without `?extended=full_anime_seasons` | anime entries | `mapped_tvdb_seasons` array omitted |
| [`GET /sync/all-items`](/api-reference/simkl/get-all-items) without `?episode_tvdb_id=yes` | episode rows | `tvdb` block on each episode omitted |
| [`GET /sync/all-items`](/api-reference/simkl/get-all-items) without `?episode_runtime=yes` | episode rows | `runtime` on each episode omitted |

See [Watchlist statuses](/conventions/list-statuses) for the full per-type matrix.

<Info>
  **`/sync/all-items/anime` entries wrap in `show:`, by design.** Each
  item in the `anime` array nests its media object under a key called
  `show:`, not `anime:`. This is intentional cross-catalog compatibility:
  apps that source their data from **TMDB or TVDB** treat anime as shows
  (those catalogs have no separate "anime" concept), so the `show:`
  wrapper keeps Simkl's sync responses drop-in compatible with code
  written against TMDB/TVDB-shaped data. Anime-only fields (`anime_type`,
  mal/anidb/anilist/kitsu IDs) sit alongside on the outer item and on
  `show.ids` respectively.

  ```js theme={"theme":{"light":"github-light","dark":"vesper"}}
  // ✓ Correct — same iteration pattern works for shows/, anime/, and
  //   future media types that follow the show-shaped TMDB/TVDB model
  const list = await fetch('/sync/all-items/anime').then(r => r.json());
  list.anime.forEach(item => {
    const metadata = item.show;          // anime use `show:` for the same
                                         // reason TVDB indexes anime under shows
    const ids = metadata.ids;            // ids.simkl, ids.mal, ids.anidb, ...
    const animeFormat = item.anime_type; // anime-specific outer-item field
  });
  ```
</Info>

**Handling**

<CodeGroup>
  ```js JavaScript {2,5} theme={"theme":{"light":"github-light","dark":"vesper"}}
  // ✓ Correct
  if ('watching' in activities.tv_shows) {
    // TV has a watching bucket
  }
  // ✗ Wrong — activities.movies.watching is undefined, not null
  if (activities.movies.watching === null) {
    /* never true */
  }
  ```

  ```python Python {2,5} theme={"theme":{"light":"github-light","dark":"vesper"}}
  # ✓ Correct
  if 'watching' in activities['tv_shows']:
      pass

  # ✗ Wrong — KeyError
  ts = activities['movies']['watching']
  ```
</CodeGroup>

<a id="type-3" />

## Type 3 — Empty result set

<Note>
  **The whole response is `null` or an empty array** — the endpoint accepted the request but there are no items to return. The URL is valid; the call returns `200`; the body is just empty.
</Note>

**Where you'll see it**

| Endpoint | When it's empty |
| - | - |
| [`GET /sync/all-items/movies/watching`](/api-reference/simkl/get-all-items) | Always — movies can't have this status, the route is reachable but the data set is structurally empty |
| [`GET /sync/all-items/movies/hold`](/api-reference/simkl/get-all-items) | Same |
| [`GET /sync/all-items/{type}/{status}`](/api-reference/simkl/get-all-items) | Any bucket the user simply has no items in |
| [`POST /search/random`](/api-reference/simkl/search-random) | Returns `{"error": "not_found"}` when no item matches the filters |

**Handling**

<CodeGroup>
  ```js JavaScript {3,8} theme={"theme":{"light":"github-light","dark":"vesper"}}
  // ✓ Correct
  const items = await fetch('/sync/all-items/movies/watching').then(r => r.json());
  if (!items) return;        // empty bucket — bail early

  // ✗ Wrong — TypeError if response is null
  const items = await fetch(...).then(r => r.json());
  items.movies.forEach(...);
  ```

  ```python Python {3,7} theme={"theme":{"light":"github-light","dark":"vesper"}}
  # ✓ Correct
  data = r.json()
  if data is None:
      return

  # ✗ Wrong — AttributeError
  for m in r.json()['movies']:
      ...
  ```
</CodeGroup>

<a id="type-4" />

## Type 4 — Unknown / data not on file

<Info>
  **Field present, value `null`** — the data point is supposed to have a value but Simkl genuinely doesn't have it yet. Render a placeholder; don't display the literal `null`.
</Info>

**Where you'll see it**

| Endpoint | Field | When it's null |
| - | - | - |
| [`GET /tv/airing`](/api-reference/simkl/get-tv-airing) | `date` | Air date not on file yet |
| [`GET /tv/episodes/{id}`](/api-reference/simkl/get-tv-episodes) | `description` | Episode synopsis unknown |
| [`GET /tv/episodes/{id}`](/api-reference/simkl/get-tv-episodes) | `img` | Episode still not on file |
| [`GET /movies/{id}`](/api-reference/simkl/get-movie) | `ratings.imdb`, etc. | No external rating known |
| [`GET /anime/{id}`](/api-reference/simkl/get-anime) | `en_title` | Localized English title not on file (e.g. Death Note returns `null`) |
| [`GET /tv/{id}`](/api-reference/simkl/get-tv-show), [`/anime/{id}`](/api-reference/simkl/get-anime) | `network` | Mirrored from the upstream TVDB record. `null` when TVDB has no network on file (mostly recent anime). |
| [`GET /tv/{id}`](/api-reference/simkl/get-tv-show), [`/anime/{id}`](/api-reference/simkl/get-anime) | `airs` | Recurring schedule not on file (older shows, anime films) |
| [`GET /tv/{id}`](/api-reference/simkl/get-tv-show), [`/anime/{id}`](/api-reference/simkl/get-anime) | `status` | Catalog `Status` column blank — rare; status is normally one of `tba`, `ended`, `airing` |
| Catalog endpoints | `runtime` | Runtime not tracked |

<Note>
  **`network` reflects the upstream catalog (TVDB).** The value is what
  TVDB classifies the record under — for streaming-only shows TVDB lists
  the platform as the "network" (`"Prime Video"`, `"Netflix"`, `"Apple TV+"`),
  for traditional broadcast it's the broadcaster (`"HBO"`, `"AMC"`, `"CBS"`).
  Don't infer distribution model from this field; treat it as an opaque
  display string from the source catalog.
</Note>

<Tip>
  **`null` vs empty string.** Older Simkl records sometimes return an empty
  string `""` for a field rather than `null` (e.g. `en_title: ""` on classic
  anime, before the API standardized on `null`). Both mean Type 4 — data
  not on file. Check both:

  ```js theme={"theme":{"light":"github-light","dark":"vesper"}}
  const englishTitle = item.en_title || 'No English title on file';
  // matches null AND empty string
  ```
</Tip>

<Note>
  **`memo: {}` is a Type 4 variant.** On
  [`GET /sync/all-items?memos=yes`](/api-reference/simkl/get-all-items),
  items the user has set a memo on return:

  ```json theme={"theme":{"light":"github-light","dark":"vesper"}}
  { "memo": { "text": "Loved this season", "is_private": false } }
  ```

  Items without a memo return an **empty object**:

  ```json theme={"theme":{"light":"github-light","dark":"vesper"}}
  { "memo": {} }
  ```

  NOT `null`, NOT missing — `{}`. The empty-object marker keeps the key
  shape consistent (client code can always do `item.memo.text || ''`
  without an existence check or a null-coalesce). When `?memos=yes` is
  NOT set, the `memo` key is omitted entirely (Type 2).
</Note>

**Handling**

<CodeGroup>
  ```jsx JSX {2,5} theme={"theme":{"light":"github-light","dark":"vesper"}}
  // ✓ Correct
  <span>{episode.description ?? 'No description yet.'}</span>

  // ✓ Correct — guard date-display code
  <span>{episode.date ? formatDate(episode.date) : 'TBA'}</span>
  ```

  ```python Python {2} theme={"theme":{"light":"github-light","dark":"vesper"}}
  # ✓ Correct
  runtime_str = f"{movie['runtime']} min" if movie.get('runtime') else "—"
  ```
</CodeGroup>

<a id="type-5" />

## Type 5 — End state reached

<Check>
  **Field present, value `null`** — the value used to be meaningful but the workflow has ended; the field is intentionally cleared. Render the "complete" state in your UI.
</Check>

**Where you'll see it**

| Endpoint | Field | When it's null |
| - | - | - |
| [`GET /sync/all-items`](/api-reference/simkl/get-all-items) | `next_to_watch` | No aired episode remains after the last one watched — the user is caught up. **Not** tied to status: a `dropped` or `hold` entry normally still carries a value |
| [`GET /sync/all-items`](/api-reference/simkl/get-all-items) `?next_watch_info=yes` | `next_to_watch_info` | Same rule — nothing left to watch |

**Handling**

<CodeGroup>
  ```jsx JSX {2-4} theme={"theme":{"light":"github-light","dark":"vesper"}}
  // ✓ Correct
  {show.next_to_watch
    ? <Badge>Next: {show.next_to_watch}</Badge>
    : <Badge variant="success">All caught up</Badge>}
  ```

  ```python Python {1,3} theme={"theme":{"light":"github-light","dark":"vesper"}}
  if show.get('next_to_watch'):
      print(f"Next: {show['next_to_watch']}")
  else:
      print("All caught up")
  ```
</CodeGroup>

<Note>
  **Check `status` too when you only want the watching list.** `next_to_watch` reports what is left to watch regardless of which list the item sits in, so filtering on it alone mixes `dropped` and `hold` entries into a "continue watching" screen. See [Hide shows the user is caught up on](/guides/sync#hide-caught-up-shows).
</Note>

## Empty / Missing response shape — by endpoint

The API uses **five different shapes** for "this request was valid but
there's no data". The table below tells you what to expect at each
endpoint so a single parsing path can handle them all.

### The five shapes you'll meet

<CardGroup cols={5}>
  <Card title="200 [ ]" icon="brackets-square">
    Empty array. Safe to iterate.
  </Card>

  <Card title="200 null" icon="circle-dashed">
    Top-level `null`. `r.json()` returns `None`.
  </Card>

  <Card title="200 { }" icon="ban">
    Empty object. `len()` is 0.
  </Card>

  <Card title="301 redirect" icon="right-from-bracket">
    Answer in `Location:` header. No body.
  </Card>

  <Card title="200 fallthrough" icon="shuffle">
    Server returned unrelated discover data instead of an error.
  </Card>
</CardGroup>

<Warning>
  **Status-code lies.** Five endpoints return `200 OK` with an empty
  shape rather than `404 Not Found`. Branch on the body shape, not just
  the status — see the rows tagged in the table below.

  Conversely, three CDN endpoints (`/calendar/...`, `/discover/...`)
  return `404` with a **plain string body**, not the JSON error envelope
  the rest of the API uses. `r.json()` will raise on these — wrap the
  parse.
</Warning>

### Per-endpoint shape matrix

| Endpoint | Trigger | Status | Body shape | How to handle |
| - | - | :-: | - | - |
| `GET /movies/{id}` | unknown numeric Simkl ID | **200** | `[]` | Empty array (Type 3). Safe to iterate. |
| `GET /tv/{id}` | unknown numeric Simkl ID | **200** | `[]` | Same. |
| `GET /anime/{id}` | unknown numeric Simkl ID | **200** | `[]` | Same. |
| `GET /tv/episodes/{id}` | unknown parent show ID | **200** | `[]` | Same. |
| `GET /anime/episodes/{id}` | unknown parent anime ID | **200** | `[]` | Same. |
| `GET /tv/episodes/{id}` | non-numeric `id` | **200** | `[]` | Same. **No 404** for non-numeric id. |
| `GET /anime/episodes/{id}` | non-numeric `id` | **200** | `[]` | Same. |
| `GET /movies/{id}` | non-numeric `id` | 404 | `{error, code, message?}` | JSON error envelope. |
| `GET /tv/{id}` | non-numeric `id` | **200** | `object{...}` | **Fallthrough** — server returns discover data (`top_aired_fanarts`, etc.) instead of an error — NOT a `ShowDetail` shape. |
| `GET /anime/{id}` | non-numeric `id` | **200** | `object{...}` | Same fallthrough behavior. |
| `GET /tv/episodes/` | empty `id` (trailing slash) | 400 | `{error: "empty_id", code: 400}` | JSON error envelope. |
| `GET /anime/episodes/` | empty `id` | 400 | `{error: "empty_id", code: 400}` | Same. |
| `GET /sync/activities` | fresh account (no activity yet) | 200 | `object{}` w/ all-null timestamps | Type 1 nulls inside, never null at top. |
| `GET /sync/all-items` | brand-new user (no library, no `type` filter) | 200 | `{}` | Empty object. Iterating keys yields nothing. |
| `GET /sync/all-items/{type}` | empty bucket | 200 | `object{type: []}` | Object wrapper present; inner array is empty. |
| `GET /sync/all-items/movies/watching` | movies can't be `watching` | **200** | `{}` | **Empty object** — not `null`, not `[]`. Iterating keys yields nothing. |
| `GET /sync/all-items/movies/hold` | movies can't be `hold` | **200** | `{}` | Same. |
| `GET /sync/all-items?memos=yes` | item with no memo set | **200** | `{ "memo": {} }` per item | Empty object inside the per-item shape — NOT null, NOT missing. Type 4 variant. |
| `GET /sync/all-items/badtype` | bad `type` segment | **200** | `object{...}` | **Fallthrough** — server returns a default response with cross-type data. **No 400/404**. |
| `GET /sync/all-items/{type}/badstatus` | bad `status` segment | 200 | `object{type: []}` | Falls back to "all of that type", just empty. **No 400/404**. |
| `GET /sync/ratings/{type}/{1-10}` | user has no ratings at this score | **200** | `{}` | Empty object, NOT empty array. |
| `GET /sync/ratings/{type}/0` | rating \< 1 (out of range) | **200** | `{}` | Same — no validation error. |
| `GET /sync/ratings/{type}/11` | rating > 10 (out of range) | **200** | `{}` | Same. |
| `GET /sync/ratings/badtype/{N}` | bad `type` | **200** | `{}` | Same. |
| `GET /sync/playback/{type}` | empty | 200 | `array[...]` | Returns array (possibly empty). |
| `GET /sync/playback/badtype` | bad `type` | 404 | `{error: "url_failed", code: 404}` | **404 here, but 200 fallthrough on /sync/all-items/badtype** — be aware of the asymmetry. |
| `GET /search/{type}` | missing `q` parameter | **200** | `[]` | Empty array. |
| `GET /search/badtype` | bad type | **200** | `null` | **Top-level null.** `r.json()` returns `None`. |
| `GET /search/id` | unknown external id | **200** | `[]` | Empty array. |
| `GET /search/id` | no id param at all | **200** | `[]` | Same. |
| `POST /search/random` | empty body | **200** | `object{...}` | Returns a random item — never empty. |
| `POST /search/random` | filters yield no match | **200** | `{error: "not_found", ...}` | **Status is 200 but body is the error envelope.** Branch on `'error' in body`, not on status. |
| `POST /search/file` | empty body | **200** | `null` | **Top-level null.** |
| `GET /ratings` | bad params, no id | **200** | `null` | **Top-level null.** |
| `GET /ratings` | unknown imdb id | **200** | `null` | Same. |
| `GET /ratings/{type}` | missing `user_watchlist` param | **200** | `null` | Same — even WITHOUT a Bearer token (auth gate is on the param). |
| `GET /users/settings` | normal | 200 | `object{user, account, connections}` | Always a populated object for the authed user. |
| `GET /users/{user_id}/stats` | unknown `user_id` | **200** | `object{...}` w/ zero stats | Returns a zero-counts record, NOT 404. (Cross-account access intentional.) |
| `GET /users/recently-watched-background/{user_id}` | numeric unknown user | **200** | `null` | **Top-level null** for a numeric-but-unknown id. |
| `GET /users/recently-watched-background/{user_id}` | non-numeric user\_id | 404 | `{error: "user_id_failed", code: 404}` | JSON error envelope. |
| `GET /redirect` | no id at all | **301** | empty body, `Location: //simkl.com` | **301 redirect** — body is empty; answer is in the header. Don't `r.json()`. |
| `GET /redirect` | unknown external id | **301** | empty body, `Location: //simkl.com` | Same — fallback to simkl.com root. |
| `GET /redirect` | valid id | **301** | empty body, `Location: //simkl.com/<type>/<id>/<slug>` | Always 301 — never returns JSON. |
| `GET /oauth/pin/{user_code}` | unknown `user_code` | 200 | `object{...}` (pending status) | Returns the pending-PIN shape, NOT 404. |
| `GET /calendar/{type}.json` *(CDN)* | normal | 200 | `array[...]` | — |
| `GET /calendar/{year}/{month}/{type}.json` *(CDN)* | far-future month | **404** | **plain string body** | **`r.json()` will raise.** Wrap the parse. |
| `GET /calendar/badtype.json` *(CDN)* | bad type | **404** | **plain string body** | Same. |
| `GET /discover/trending/{file}.json` *(CDN)* | normal | 200 | `array[...]` or `object{...}` (combined) | — |
| `GET /discover/trending/badcombo.json` *(CDN)* | unknown filename | **404** | **plain string body** | Same — CDN errors are not JSON. |

### Defensive parser pattern

If you're writing a generic Simkl client wrapper, this defensive
pattern handles every shape above:

<CodeGroup>
  ```js JavaScript theme={"theme":{"light":"github-light","dark":"vesper"}}
  async function safeJson(response) {
    // CDN 404s and the redirect-without-follow are not JSON.
    if (response.status >= 300 && response.status < 400) return null;
    if (!response.headers.get('content-type')?.includes('json')) return null;
    let body;
    try { body = await response.json(); }
    catch { return null; }                     // bad-content-type 404 string
    if (body === null) return null;            // top-level null (Type 3)
    if (Array.isArray(body) && body.length === 0) return [];   // empty array
    if (typeof body === 'object' &&
        Object.keys(body).length === 0) return {};             // empty object
    if (body && typeof body === 'object' &&
        'error' in body && response.status === 200) {
      // 200-with-error-envelope (e.g. POST /search/random with no match)
      return null;
    }
    return body;
  }
  ```

  ```python Python theme={"theme":{"light":"github-light","dark":"vesper"}}
  def safe_json(r):
      """Returns the parsed body or None for any "no useful data" shape."""
      if 300 <= r.status_code < 400:
          return None                                      # redirect — header has the answer
      ct = (r.headers.get("content-type") or "").lower()
      if "json" not in ct:
          return None                                      # CDN 404 string body
      try:
          body = r.json()
      except ValueError:
          return None
      if body is None:
          return None                                      # top-level null
      if isinstance(body, list) and not body:
          return []                                        # empty array
      if isinstance(body, dict):
          if not body:
              return {}                                    # empty object
          if r.status_code == 200 and "error" in body:
              return None                                  # 200-with-error envelope
      return body
  ```
</CodeGroup>

### Pinning a shape in your code

If your integration depends on a specific row of this table, pin it as
a comment in your code. When the table changes on a future doc release,
your in-code reference makes the diff easier to spot during a routine
dependency-update review.

## Quick decision table

| Symptom | Type | Means | Do |
| - | :-: | - | - |
| Timestamp `null` on activities | **1** | Never happened yet | Don't show "last activity" |
| Key isn't in the object | **2** | Doesn't apply to type | `'key' in obj` check, or skip silently |
| Whole response is `null` | **3** | Bucket is empty | Render empty state |
| Description/image is `null` | **4** | Data not on file | Render placeholder ("TBA", "—") |
| `next_to_watch` `null` on a show | **5** | Nothing left to watch | Render "all caught up" |

## Related

<CardGroup cols={2}>
  <Card title="Watchlist statuses" icon="list-check" href="/conventions/list-statuses">
    Which statuses are valid per type. The reason `watching` and `hold` are omitted from movie blocks (Type 2).
  </Card>

  <Card title="Standard media objects" icon="film" href="/conventions/standard-media-objects">
    Field-by-field shape of Movie / Show / Anime / Episode — which fields can be null, which are always present.
  </Card>

  <Card title="Dates and timezones" icon="clock" href="/conventions/dates">
    ISO-8601, the `Z` suffix, and the `1970-01-01T00:00:01Z` "very long time ago" placeholder.
  </Card>

  <Card title="Errors" icon="triangle-exclamation" href="/conventions/errors">
    How error envelopes differ from null responses, and when you'll see each.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.