laurent22/joplin · error · Error

Validation error: title must be ${maxTitleLength} characters

Error message

Validation error: title must be ${maxTitleLength} characters or less

What it means

Thrown by BaseModel.userSideValidation() when o.title is a string longer than maxTitleLength (hardcoded 4096). Titles longer than this would exceed the SQLite column / sync payload limits Joplin relies on, so they are rejected at the validation boundary.

Source

Thrown at packages/lib/BaseModel.ts:622

		query.id = modelId;
		query.modObject = o;

		return query;
	}

	public static userSideValidation(o: Record<string, unknown>) {
		if (typeof o.id === 'string' && !o.id.match(/^[a-f0-9]{32}$/)) {
			throw new Error('Validation error: ID must a 32-characters lowercase hexadecimal string');
		}

		const timestamps = ['user_updated_time', 'user_created_time'] as const;
		for (const k of timestamps) {
			if ((k in o) && (typeof o[k] !== 'number' || isNaN(o[k] as number) || (o[k] as number) < 0)) throw new Error('Validation error: user_updated_time and user_created_time must be numbers greater than 0');
		}

		const maxTitleLength = 4096;
		if (typeof o.title === 'string' && o.title.length > maxTitleLength) {
			throw new Error(`Validation error: title must be ${maxTitleLength} characters or less`);
		}

		// Null bytes break Joplin's serialised note format and can cause silent
		// truncation in some HTTP clients (notably React Native on iOS).
		const nul = String.fromCharCode(0);
		for (const k of Object.keys(o)) {
			const v = o[k];
			if (typeof v === 'string' && v.includes(nul)) {
				throw new Error(`Validation error: ${k} cannot contain a null byte`);
			}
		}
	}

	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- o is any BaseItemEntity subclass being saved; subclasses override save() with stricter per-entity types
	public static async save(o: any, options: SaveOptions = null) {
		// When saving, there's a mutex per model ID. This is because the model returned from this function
		// is basically its input `o` (instead of being read from the database, for performance reasons).
		// This works well in general except if that model is saved simultaneously in two places. In that

View on GitHub (pinned to 2654b33620)

Solutions

  1. Truncate the title to <= 4096 chars before save (title.slice(0, 4096)).
  2. Move long content into the note body, not the title.
  3. For importer code, verify the field mapping so body content does not land in title.

Example fix

// before
await Note.save({ title: veryLongDocumentText });
// after
await Note.save({ title: veryLongDocumentText.slice(0, 4096), body: veryLongDocumentText });
Defensive patterns

Strategy: validation

Validate before calling

const MAX_TITLE = 4096;
if (typeof entity.title === 'string' && entity.title.length > MAX_TITLE) {
  throw new Error(`Title too long (${entity.title.length} > ${MAX_TITLE})`);
}

Type guard

const isWithinTitleLimit = (t, max = 4096) => typeof t !== 'string' || t.length <= max;

Try / catch

try { BaseModel.userSideValidation(entity); }
catch (e) { if (/title must be/.test(e.message)) { entity.title = entity.title.slice(0, 4096); } else throw e; }

Prevention

When it happens

Trigger: userSideValidation(o) with typeof o.title === 'string' and o.title.length > 4096 — e.g. pasting a very long document as a note title, or an importer that puts the body in the title field.

Common situations: Programmatic note creation where body was accidentally assigned to title; importer mapping wrong field; concatenation that produced an oversized title.

Related errors


AI-assisted analysis of laurent22/joplin@2654b33620 (2026-08-12). Data as JSON: /api/errors/fcb151c5753995d8. Report an issue: GitHub.