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 thatView on GitHub (pinned to 2654b33620)
Solutions
- Truncate the title to <= 4096 chars before save (title.slice(0, 4096)).
- Move long content into the note body, not the title.
- 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
- Truncate titles at the import boundary, not at save time.
- Map long-form content to `body`, never `title`, when importing.
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
- Validation error: ID must a 32-characters lowercase hexadeci
- Validation error: user_updated_time and user_created_time mu
- Validation error: ${k} cannot contain a null byte
- Cannot change encrypted item
- Cannot find "%s".
AI-assisted analysis of laurent22/joplin@2654b33620 (2026-08-12).
Data as JSON: /api/errors/fcb151c5753995d8.
Report an issue: GitHub.