n8n-io/n8n · error · InvalidScheduleError

${schedule.kind}.cronExpression must be a string, got ${JSON

Error message

${schedule.kind}.cronExpression must be a string, got ${JSON.stringify(expression)}

What it means

Thrown by validateCron when schedule.cronExpression is not a string. Because raw DB rows may reach the validator untyped, the validator defensively narrows `unknown` before any cron parsing. A null, number, or object expression is rejected before it can crash CronExpressionParser.

Source

Thrown at packages/@n8n/scheduler/src/core/recurrence/kinds/cron.ts:29

 * - 5 fields (standard cron, seconds = 0)
 * - 6 fields (the first is seconds)
 */
const MIN_CRON_FIELD_COUNT = 5;
const MAX_CRON_FIELD_COUNT = 6;

export type CronCursor = ReturnType<typeof CronExpressionParser.parse>;

/**
 * Checks that a cron schedule is usable: a 5- or 6-field expression in a real
 * timezone. Also used for `recurring_cron`, which reuses the cron expression.
 * @param schedule The cron (or recurring_cron) schedule to check.
 * @throws {InvalidScheduleError} When the expression or timezone is invalid.
 */
export function validateCron(schedule: CronSchedule | RecurringCronSchedule): void {
	// Raw DB rows may reach here untyped, so check the runtime type before use.
	const expression: unknown = schedule.cronExpression;
	if (typeof expression !== 'string') {
		throw new InvalidScheduleError(
			`${schedule.kind}.cronExpression must be a string, got ${JSON.stringify(expression)}`,
		);
	}

	const fieldCount = expression.trim().split(/\s+/).length;
	if (fieldCount < MIN_CRON_FIELD_COUNT || fieldCount > MAX_CRON_FIELD_COUNT) {
		throw new InvalidScheduleError(
			`Cron expression must have ${MIN_CRON_FIELD_COUNT} or ${MAX_CRON_FIELD_COUNT} fields (seconds optional), got ${fieldCount}: ${JSON.stringify(expression)}`,
		);
	}

	// A null timezone is the instance default, resolved by the caller.
	if (schedule.timezone !== null && !IANAZone.isValidZone(schedule.timezone)) {
		throw new InvalidScheduleError(`Unknown IANA timezone: ${JSON.stringify(schedule.timezone)}`);
	}

	try {
		CronExpressionParser.parse(expression, { tz: schedule.timezone ?? 'UTC' });

View on GitHub (pinned to 5ac6606e81)

Solutions

  1. Ensure cron_expression is stored as a non-null VARCHAR/TEXT in the DB.
  2. Coerce request input: if (typeof raw !== 'string') reject before validateCron.
  3. Backfill null cron_expression rows with a default or delete them.
  4. Type the schedule literal with cronExpression: string and let TS catch the mistake at compile time.

Example fix

// before
validateCron({ kind: 'cron', cronExpression: row.cron_expression, timezone: row.tz }); // row.cron_expression is null -> throws

// after - guard at the boundary
if (typeof row.cron_expression !== 'string') {
  throw new Error(`row ${row.id} has non-string cron_expression`);
}
validateCron({ kind: 'cron', cronExpression: row.cron_expression, timezone: row.tz });
Defensive patterns

Strategy: type-guard

Validate before calling

function coerceCronExpression(raw: unknown): string {
  if (typeof raw !== 'string' || raw.length === 0) {
    throw new Error('cronExpression must be a non-empty string');
  }
  return raw;
}

validateCron({ kind: 'cron', cronExpression: coerceCronExpression(row.cron_expression), timezone: row.tz });

Type guard

function isStringExpression(v: unknown): v is string {
  return typeof v === 'string' && v.length > 0;
}

Prevention

When it happens

Trigger: Loading a cron or recurring_cron schedule from a DB row where cron_expression is NULL, an integer, an empty object, or a JSON-parsed array; passing a schedule literal with cronExpression typo'd as expression.

Common situations: A migration that left cron_expression null for legacy rows; an ORM that returned the column as a parsed JSON object; constructing a schedule from request body without coercion; a test fixture that set cronExpression: undefined.

Related errors


AI-assisted analysis of n8n-io/n8n@5ac6606e81 (2026-08-12). Data as JSON: /api/errors/605f94da8ad91a9f. Report an issue: GitHub.