immich-app/immich · error · Error
Invalid value for 'numResults': ${numResults}
Error message
Invalid value for 'numResults': ${numResults} What it means
This is a hand-rolled runtime guard inside Immich's SearchRepository.searchFaces, the vchordrq (pgvector) face-embedding similarity query. Before the SQL transaction runs, numResults (the LIMIT for the nearest-neighbor search) is validated with zod as z.int().min(1).max(1000); any non-integer, 0, negative, NaN, undefined, or value above 1000 throws this plain Error, aborting the facial-recognition job. The guard exists because numResults is passed straight into .limit() on an expensive vector index scan, so out-of-range values would produce degenerate or runaway queries.
Source
Thrown at server/src/repositories/search.repository.ts:355
params: [DummyValue.UUID],
})
async getEmbedding(assetId: string) {
return this.db.selectFrom('smart_search').selectAll().where('assetId', '=', assetId).executeTakeFirst();
}
@GenerateSql({
params: [
{
userIds: [DummyValue.UUID],
embedding: DummyValue.VECTOR,
numResults: 10,
maxDistance: 0.6,
},
],
})
searchFaces({ clusterGroupId, embedding, numResults, maxDistance, hasPerson, minBirthDate }: FaceEmbeddingSearch) {
if (!z.int().min(1).max(1000).safeParse(numResults).success) {
throw new Error(`Invalid value for 'numResults': ${numResults}`);
}
return this.db.transaction().execute(async (trx) => {
await sql`set local vchordrq.probes = ${sql.lit(probes[VectorIndex.Face])}`.execute(trx);
return await trx
.with('cte', (qb) =>
qb
.selectFrom('asset_face')
.innerJoin('asset', 'asset.id', 'asset_face.assetId')
.innerJoin('face_search', 'face_search.faceId', 'asset_face.id')
.select([
'asset_face.id',
'asset_face.personGroupId',
sql<number>`face_search.embedding <=> ${embedding}`.as('distance'),
])
.where('asset.ownerId', 'in', (eb) =>
eb.selectFrom('user').select('user.id').where('user.clusterGroupId', '=', clusterGroupId),
)View on GitHub (pinned to 5666d57f15)
Solutions
- Set Machine Learning > Facial Recognition > Minimum faces back to an integer between 1 and 1000 in Administration > Settings, then re-run the Facial Recognition job — this fixes the common config-driven case.
- If you call searchFaces directly, pass an integer clamped to 1..1000: Math.min(1000, Math.max(1, Math.trunc(numResults))).
- Harden the config DTO so invalid values are rejected at save time: change minFaces in src/dtos/config.dto.ts to z.int().min(1).max(1000) to match the repository contract.
- Check the facial-recognition queue logs to confirm which value was actually received — the message interpolates the offending numResults value.
Example fix
// before (src/services/person.service.ts / admin config)
// admin sets facialRecognition.minFaces = 5000; DTO allows it (no max)
numResults: machineLearning.facialRecognition.minFaces, // throws: Invalid value for 'numResults': 5000
// after — enforce the repository contract at the config boundary (src/dtos/config.dto.ts)
minFaces: z
.int()
.min(1)
.max(1000) // match searchFaces' z.int().min(1).max(1000)
.describe('Minimum number of faces required for recognition') Defensive patterns
Strategy: validation
Validate before calling
// Before calling searchFaces (or queueing facial recognition with a custom minFaces):
import { z } from 'zod';
const NumResults = z.int().min(1).max(1000);
const parsed = NumResults.safeParse(numResults);
if (!parsed.success) {
// clamp instead of aborting a long-running recognition job
numResults = Math.min(1000, Math.max(1, Math.trunc(Number(numResults) || 1)));
} Type guard
import { z } from 'zod';
const isValidNumResults = (value: unknown): value is number =>
z.int().min(1).max(1000).safeParse(value).success; Try / catch
// Inside a job handler / wrapper around searchFaces:
try {
return await this.searchRepository.searchFaces({ ... });
} catch (error) {
if (error instanceof Error && error.message.startsWith("Invalid value for 'numResults'")) {
this.logger.warn(`Skipping face ${id}: ${error.message} (check facialRecognition.minFaces config)`);
return JobStatus.Failed; // do not retry an unchanged config value
}
throw error;
} Prevention
- Keep machineLearning.facialRecognition.minFaces within 1..1000 — mirror the repository's .max(1000) in the config DTO so bad values are rejected at save time.
- Never derive numResults from unbounded inputs (array lengths, user counts) without clamping.
- Unit-test boundary values 0, 1, 1000, 1001 and non-integers against the searchFaces contract.
When it happens
Trigger: The main production caller is PersonService.handleRecognizeFaces (server/src/services/person.service.ts:501), which passes machineLearning.facialRecognition.minFaces directly as numResults. The config DTO only enforces z.int().min(1) with NO upper bound (server/src/dtos/config.dto.ts:243-247), so setting Facial Recognition > Minimum faces to a value above 1000 in the admin UI makes every FacialRecognition job queue item throw this error. It is also thrown when any direct caller of searchFaces passes 0, a float like 10.5, a numeric string, NaN, or an unbounded computed value (e.g. array.length when the array is empty).
Common situations: Admins raising the 'minimum faces for recognition' threshold very high to suppress noisy person clusters, then facial recognition silently failing for all jobs. Custom forks or scripts calling searchRepository.searchFaces with a dynamically derived count. Type drift after refactors where numResults becomes a string or optional field (undefined fails the zod parse).
Related errors
- Invalid environment variables: \n - [${path}] ${issue.messa
- The ${name} extension is not available in this Postgres inst
- The ${name} extension version is ${version}, which mean
- The ${name} extension version is ${version}, but Immich only
- The database currently has ${name} ${installedVersion} activ
AI-assisted analysis of immich-app/immich@5666d57f15 (2026-08-21).
Data as JSON: /api/errors/8bb4e3838a846e70.
Report an issue: GitHub.