Symptom: A Relation Everyone Believes Is Empty

A document management print preview kept rendering “Uploader: —” for every report. The database had the data. The API returned HTTP 200 OK. Yet the uploadedBy relation came back as null, with no error or warning anywhere.

Users assumed reports had no uploader assigned. Authors assumed the UI was broken. Nobody suspected the API, because it was perfectly silent.

Where the Bug Lives in the Stack

Before the curl session, it helps to fix where the failure happens:

Mermaid Diagram

Only the ORM layer translates populate[uploadedBy]=true into actual JOINs. The DB only answers the SQL it gets, and the controller layer doesn’t know which tables exist. If a relation comes back empty, the ORM is the only suspect.

The Bug, in One Sentence

populate[uploadedBy]=true works on its own. filters[owner][id][$eq]=N works on its own. Combine them, and uploadedBy is silently dropped.

Reproducing It

Two requests, only the filter shape differs:

# A — filter by numeric id + populate → null (BROKEN)
curl "$API/api/reports?\
filters[owner][id][\$eq]=3807&\
populate[uploadedBy]=true"
# => { "uploadedBy": null }

# B — filter by documentId + populate → works
curl "$API/api/reports?\
filters[owner][documentId][\$eq]=m5cixrk1ekf3qj6gjb9zfijl&\
populate[uploadedBy]=true"
# => { "uploadedBy": { "id": 85, "realName": "J. Chen" } }

Both queries hit the same report set. Both ask for the same populate. The only difference is how the owner is identified — which should be an internal detail, not a feature gate. Yet:

Query ShapeuploadedByStatusError in Logs
filters[owner][id] + populatenull200 OKnone
filters[owner][documentId] + populatepopulated ✅200 OKnone
populate only (no filter)populated ✅200 OKnone

If Strapi had returned a 500 or even a 200 with { error: "populate skipped" }, this would have been a 30-second fix. Silently dropping the relation turns it into a multi-hour spelunking exercise.

Why It’s So Hard to Catch

  • No stack trace, no thrown exception
  • No error field on the response
  • Every other property of the record is correct (date, type, files…)
  • Front-end code that does uploadedBy?.realName simply renders -

You end up doubting the data, the front-end, the auth — anything but the API. Only when you compare two near-identical queries side-by-side does the pattern surface.

In our case, three things made the diagnosis painfully slow:

  1. The data was real. Direct psql queries against reports_uploaded_by_lnk showed the link rows were there. So we wasted time hunting for “missing data” that was never missing.
  2. One developer’s seed data worked. A teammate using the Strapi admin to seed a few records would happen to filter by documentId while debugging, and the relation appeared. The bug “didn’t reproduce” until we wrote the exact same filters[id] query the production frontend was generating.
  3. No tests covered it. The relation appears correctly in findOne(documentId) calls, which is what most automated tests cover. The list-with-filter shape is what the print preview screen actually uses, and it had no integration test of its own.

This is the shape of every silent-failure bug: the symptom is gentle, the data looks plausible, and the “happy path” tests still pass.

Workaround: A knex-Based Custom find Controller

The fix is to drop one layer — from Strapi’s ORM down to the knex query builder it ships with — and own the JOIN explicitly:

// src/api/report/controllers/report.ts
async find(ctx) {
  const ownerId = (ctx.query as any)?.filters?.owner?.id?.$eq;

  // If no owner filter, let Strapi handle it normally
  if (!ownerId) return super.find(ctx);

  const knex = strapi.db.connection;

  // 1. Fetch the reports for that owner via the link table
  const reports = await knex('reports')
    .leftJoin('reports_owner_lnk as ol', 'ol.report_id', 'reports.id')
    .where('ol.owner_id', ownerId)
    .select('reports.*');

  // 2. Pull uploadedBy with a dedicated LEFT JOIN on its link table —
  //    this is the part Strapi was silently skipping
  const reportIds = reports.map(r => r.id);
  const uploaders = await knex('reports_uploaded_by_lnk')
    .leftJoin('up_users', 'up_users.id', 'reports_uploaded_by_lnk.user_id')
    .whereIn('reports_uploaded_by_lnk.report_id', reportIds)
    .select(
      'reports_uploaded_by_lnk.report_id',
      'up_users.id', 'up_users.real_name', 'up_users.title',
    );

  // 3. Stitch the response together explicitly — no silent drops
  const byReport = Object.fromEntries(uploaders.map(u => [u.report_id, u]));
  return {
    data: reports.map(r => ({ ...r, uploadedBy: byReport[r.id] ?? null })),
  };
}

Three things worth noting:

  1. It only intercepts the broken query shape. When filters.owner.id is absent, we fall through to super.find(ctx) so we don’t regress every other caller.
  2. Link tables are the source of truth. Strapi v5 uses *_lnk tables for every relation; joining them directly is exactly as correct as populate would have been — just spelled out.
  3. null becomes intentional, not accidental. If byReport[r.id] is missing, that means we genuinely didn’t find an uploader, and the explicit ?? null documents that.

This is not officially documented as a Strapi bug, and Strapi v5’s populate API doesn’t warn about the interaction with id-based relation filters. Until upstream fixes it, owning the JOIN is the only deterministic option.

Alternatives Considered

Before committing to the custom controller, three other paths got ruled out:

  • Switch every caller to documentId filters. Tempting, but it leaks Strapi’s internal id model into every frontend that consumes this API. Some callers (mobile apps with cached numeric ids) couldn’t migrate cleanly anyway.
  • Patch the upstream entity service. Possible in theory, but Strapi’s populate code path is non-trivial and any fork becomes a maintenance burden on every minor upgrade.
  • Add a post-fetch hydration step that calls /api/users/:id per report. Functionally equivalent, but turns one query into N+1 queries — bad for any owner with more than a handful of records, and re-introduces a different silent-failure mode if any of the user lookups 401s.

The knex approach trades some controller verbosity for one extra LEFT JOIN. That’s the right trade for this codebase.

The General Lesson

When an ORM returns null instead of throwing on an ambiguous query, silence becomes a bug amplifier. A relation field you asked for that comes back empty is no longer just data — it’s a signal worth investigating.

If your hot path depends on a particular relation loading, consider owning that join in raw SQL or a query builder. A slightly more verbose controller is cheap insurance against a silent regression.


For a language-agnostic version of this anti-pattern across Prisma, TypeORM, Mongoose, SQLAlchemy, and Django, plus a more general framing of why ORMs sometimes silently drop relations:

👉 ORM 在騙你:當 populate / include 悄悄失效 (zh-TW)