Back to Docling

DoclingDocument Version Compatibility Strategy

.plans/active/docling-compatibility.md

2.111.019.0 KB
Original Source

DoclingDocument Version Compatibility Strategy

Generated against library versions v2.36.0 – v2.85.0, schema versions 1.4.0 → 1.10.0 (50 releases).


Part 1 — Change Taxonomy: Last 50 Releases

Overview

In 50 library releases the schema version field embedded in every DoclingDocument JSON was only bumped 6 times (1.4 → 1.5 → 1.6 → 1.7 → 1.8 → 1.9 → 1.10), yet there were 36 individual schema-touching commits. 30 of those 36 shipped additive model changes without any schema version bump and without any downgrade-projection companion.

Category legend

SymbolCategoryPydantic behaviour on old client
🟡New enum value addedHard crash — enum membership check fails
🔵New optional field / model addedHard crash — extra="forbid" on NodeItem
🟣New DocItem subtype in unionHard crash — discriminator lookup fails
🔴Enum value removed / renamedSilent wrong result (aliases usually kept)
🟢New required fieldHard crash — missing field error
🟠New stricter validation ruleOld documents rejected by new client

Count summary (50-release vs 30-release window)

Category50-releaseOld client crash?
🟡 New enum value14Yes — hard crash
🔵 New optional field / model13Yes — hard crash (extra="forbid")
🟣 New DocItem subtype in union4Yes — discriminator fail
🔴 Enum value removed / renamed3Partial — silent wrong result
🟢 New required field0
🟠 New stricter validation rule2Old data fails on new client
Total schema-touching commits36
— with schema version bump6
without schema version bump30

Zero required-field additions in all 50 releases. Every breaking change is either a new optional field or a new enum value — confirming that the entire problem space is solvable with two targeted changes (see Part 2).


Schema version discipline — full history

Schema bumpLibrary versionDowngrade projector?Changes covered
1.0.0 → 1.1.0pre-v2.36✅ Yestransform_to_content_layer migrates old PAGE_HEADER/PAGE_FOOTER — the only ever-written projector
1.1.0 → 1.2.0pre-v2.36❌ NoInline groups, revamped Markdown export
1.2.0 → 1.3.0pre-v2.36❌ NoSerializers, text formatting
1.3.0 → 1.4.0pre-v2.36❌ NoTable annotations
1.4.0 → 1.5.0v2.38.2❌ NoRemodelled lists, ListGroup, deprecated UnorderedList/OrderedList
1.5.0 → 1.6.0v2.45.0❌ NoRich table cells (RichTableCell, AnyTableCell union)
1.6.0 → 1.7.0v2.47.0❌ NoFillable TableCell.fillable field
1.7.0 → 1.8.0v2.49.0❌ NoMetadata model hierarchy (BasePrediction, BaseMeta, meta field on NodeItem)
1.8.0 → 1.9.0v2.57.0❌ NoFineRef, DocItem.comments
1.9.0 → 1.10.0v2.69.0❌ NoField data model, 7 new DocItemLabel values, new top-level lists

Commit-level classification

v2.36 – v2.56 (new in 50-release window)

Library ver.Schema ver.PRCategoryWhat changedClient impact
v2.38.21.4 → 1.5#339🟣 New subtype, 🔴 Enum renamedUnorderedList/OrderedList replaced by ListGroup in groups union. GroupLabel.ORDERED_LIST deprecated.Hard crash: old union does not know ListGroup discriminator.
v2.39.01.5 (no bump)#345🟡 New enum valuesAdded ContentLayer.INVISIBLE = "invisible" and ContentLayer.NOTES = "notes".Hard crash: Pydantic enum validation fails for content_layer="invisible" or "notes".
v2.44.21.5 (no bump)#349🟠 New validation ruleAdded _validate_rules(): enforces ListGroup children are ListItems, non-root groups are non-empty.Old data with mixed-child ListGroup now rejected by new client.
v2.45.01.5 → 1.6#368🟣 New subtype, 🔵 New fieldAdded RichTableCell with ref field. TableData.table_cells changed to list[AnyTableCell].Hard crash: extra ref field rejected by extra="forbid" on old TableCell parser.
v2.46.01.6 (no bump)#378🔵 New field (internal)Added DoclingDocument.filter(page_nrs) API, internal _DocIndex helper. No new serialised fields.Low risk — internal only.
v2.47.01.6 → 1.7#384🔵 New optional fieldAdded TableCell.fillable: bool = False.Hard crash if old model is strict and receives fillable=True.
v2.49.01.7 → 1.8#408🔵 New optional fieldIntroduced _ExtraAllowingModel, BasePrediction, BaseMeta, meta: Optional[BaseMeta] on NodeItem/FloatingItem.Hard crash on meta field — NodeItem has extra="forbid".
v2.50.01.8 (no bump)#413🟡 New enum valueAdded CodeLanguageLabel.JSON = "JSON".Hard crash for old clients receiving a CodeItem with code_language="JSON".
v2.57.01.8 → 1.9#465🔵 New field, 🟣 New subtypeAdded DocItem.comments: list[FineRef]. New FineRef subclass with range field.Hard crash on comments field with strict model.
v2.60.21.9 (no bump)#484🟡 New enum valuesAdded several CodeLanguageLabel values (Linguist alignment).Hard crash for old clients receiving CodeItems with new language labels.
v2.61.01.9 (no bump)#426🔵 New field, 🟣 New subtypeAdded DocItem.source: list[SourceType]. New BaseSource/TrackSource models with kind discriminator. No version bump — no signal to client.Hard crash on source field with strict model.
v2.62.01.9 (no bump)#502🔵 New optional fieldAdded BitMapResource.image: Optional[ImageRef] and BitMapResource.mode: ImageRefMode. Deprecated uri.Hard crash on image/mode fields with strict model.
v2.63.01.9 (no bump)#507🔵 New field, 🔴 Type renamedAdded PdfShape and SegmentedPdfPage.shapes. Deprecated PdfLine/lines.Hard crash on shapes field with strict model.
v2.64.01.9 (no bump)#515🔵 New optional fieldAdded PdfPage.widgets: list[PdfWidget], PdfPage.hyperlinks: list[PdfWidget].Hard crash on widgets/hyperlinks fields with strict model.
v2.65.01.9 (no bump)#516🔴 Type correctedFixed hyperlinks element type from PdfWidgetPdfHyperlink.Clients at exactly v2.64 see a type mismatch. Low real-world impact.
v2.69.01.9 → 1.10#519🟣 New subtypes, 🟡 New enum valuesAdded FieldRegionItem, FieldHeadingItem, FieldItem, FieldValueItem in texts union. Added field_regions, field_items top-level lists. Added 7 DocItemLabel values.Hard crash: discriminator fails; unknown top-level keys.
v2.70.11.10 (no bump)#529🔴 Enum values removed, 🟡 New valuesSynced PictureClassificationLabel with v2.0 ML model: removed 11 primary values, added 14 new ones. Old values kept as deprecated aliases.Hard crash for old clients receiving new label values.
v2.70.21.10 (no bump)#573🔵 New optional fieldAdded CodeMetaField and FloatingMeta.code: Optional[CodeMetaField].Hard crash on code meta field with strict model.
v2.70.21.10 (no bump)#561🟡 New enum valueAdded DocItemLabel.HANDWRITTEN_TEXT = "handwritten_text".Hard crash when old client receives a node with label "handwritten_text".
v2.70.21.10 (no bump)#565🟠 New validation ruleAdded _validate_unique_refs: raises on duplicate self_ref.Old data with duplicate refs now rejected by new client.
v2.72.01.10 (no bump)#579🟡 New enum valuesAdded CodeLanguageLabel.DOCLANG, LATEX, TIKZ.Hard crash for old clients receiving CodeItems with these labels.
v2.76.01.10 (no bump)#611🔵 New optional fields, 🟡 New enumAdded BaseMeta.language, BaseMeta.entities, LanguageMetaField, EntitiesMetaField, HumanLanguageLabel (~180 values).Hard crash on language/entities fields with strict model.
v2.77.11.10 (no bump)#622🔵 New optional field, 🟡 New enumAdded Orientation enum (ROT_0/90/180/270), TableData.orientation: Orientation.Hard crash on orientation field + new enum values with strict model.
v2.78.11.10 (no bump)#617🔵 New optional fields, 🟡 New enumAdded BaseMeta.keywords, BaseMeta.topics, KeywordsMetaField, TopicsMetaField, MetaFieldName.KEYWORDS/TOPICS.Hard crash on keywords/topics fields with strict model.
v2.83.11.10 (no bump)#654🟡 New enum valueAdded PictureClassificationLabel.OTHER_CHART = "other_chart". Deprecated CHART.Hard crash when old client receives a picture with label "other_chart".

Problem framing

DoclingDocument has two independent versions:

VersionLocationCurrent valueWhat it governs
Library versionpyproject.toml2.86.0Python package release cadence
Schema versionCURRENT_VERSION in document.py1.10.0Data schema embedded in every serialised document

The existing check_version_is_compatible validator raises a hard ValueError when doc.minor > sdk.minor, and NodeItem carries model_config = ConfigDict(extra="forbid"), so any unknown field also crashes immediately. Both are correctness guards, but they make clients brittle against additive server changes.


Layer 1 — Make the client a Tolerant Reader (highest leverage)

The single most impactful change. Requires only a docling-core upgrade on the client; no server-side negotiation needed.

1a. Enum coercion via BeforeValidator

Replace bare enum validation on fields like content_layer with a coercing wrapper that falls back gracefully and emits a warning instead of crashing:

python
# In document.py (or a shared compat module)
def _coerce_content_layer(v: object) -> object:
    """Accept any unknown enum value; log a warning and fall back to BODY."""
    if isinstance(v, str) and v not in ContentLayer._value2member_map_:
        import logging
        logging.getLogger(__name__).warning(
            "Unknown ContentLayer value %r — treating as BODY", v
        )
        return ContentLayer.BODY
    return v

# On NodeItem:
content_layer: Annotated[
    ContentLayer, BeforeValidator(_coerce_content_layer)
] = ContentLayer.BODY

This applies equally to DocItemLabel, CodeLanguageLabel, PictureClassificationLabel, and any other enum field on a serialised model.

1b. Switch extra="forbid"extra="ignore" on NodeItem

python
class NodeItem(BaseModel):
    model_config = ConfigDict(extra="ignore")   # was "forbid"

Unknown fields from a newer server are silently dropped instead of crashing. This alone handles every "new optional field" case observed in the 50-release analysis.

Trade-off: extra="ignore" means typos in field names during construction are also silently dropped. Mitigate by keeping extra="forbid" in unit tests (inject a strict config via a test fixture) or by running strict validation only at construction time, not at wire-deserialization time.

1c. Soften the version validator

Downgrade from a hard error to a warning for same-major, higher-minor documents:

python
@field_validator("version")
@classmethod
def check_version_is_compatible(cls, v: str) -> str:
    sdk_match = re.match(VERSION_PATTERN, CURRENT_VERSION)
    doc_match = re.match(VERSION_PATTERN, v)
    if doc_match is None or sdk_match is None:
        raise ValueError(f"Cannot parse version {v!r}")
    sdk_major = int(sdk_match["major"])
    doc_major, doc_minor = int(doc_match["major"]), int(doc_match["minor"])
    sdk_minor = int(sdk_match["minor"])
    if doc_major != sdk_major:
        raise ValueError(
            f"Doc major version {doc_major} != SDK major {sdk_major}: incompatible."
        )
    if doc_minor > sdk_minor:
        warnings.warn(
            f"Doc schema {v} is newer than SDK schema {CURRENT_VERSION}. "
            "Unknown fields and enum values will be ignored.",
            UserWarning,
            stacklevel=2,
        )
    return v   # keep the original version — do not overwrite with SDK version

Preserving the original version string (rather than overwriting with CURRENT_VERSION) lets downstream code inspect what schema version was actually received.


Layer 2 — Server-side downgrade projectors

Every schema minor bump must be accompanied by a registered projector that can reduce a newer document to one parseable by the previous minor version. Ship a docling_core/compat.py module:

python
# docling_core/compat.py
from packaging.version import Version

_projectors: dict[tuple[int, int], callable] = {}

def register_projector(from_minor: int, to_minor: int):
    """Decorator: register a function that downgrades from_minor → to_minor."""
    def decorator(fn):
        _projectors[(from_minor, to_minor)] = fn
        return fn
    return decorator

def project_to(doc: "DoclingDocument", target_version: str) -> "DoclingDocument":
    """Return a copy of doc projected to be parseable by target_version SDK."""
    target = Version(target_version)
    current = Version(doc.version)
    data = doc.model_dump(mode="python")
    for minor in range(int(current.minor), int(target.minor), -1):
        fn = _projectors.get((minor, minor - 1))
        if fn:
            data = fn(data)
    from docling_core.types.doc.document import DoclingDocument
    return DoclingDocument.model_validate(data)

# Example — ships alongside the 1.11.0 schema bump:
@register_projector(from_minor=11, to_minor=10)
def _project_1_11_to_1_10(data: dict) -> dict:
    """Map SOCIAL content layer to BODY for clients at schema 1.10."""
    for item in data.get("texts", []):
        if item.get("content_layer") == "social":
            item["content_layer"] = "body"
    data["version"] = "1.10.0"
    return data

The server reads an Accept-Schema-Version request header and runs the projection chain before returning JSON:

python
# Server-side (conceptual)
doc: DoclingDocument = converter.convert(pdf)
client_version = request.headers.get("Accept-Schema-Version", CURRENT_VERSION)
if Version(client_version) < Version(doc.version):
    doc = project_to(doc, target_version=client_version)
return doc.model_dump_json()

Rule: Every schema minor bump (1.10 → 1.11) must be accompanied by a @register_projector function committed in the same PR. Enforce this with a unit test that asserts len(_projectors) == CURRENT_MINOR - 1.


Layer 3 — Schema version advertisement (low cost, optional)

Expose the server's schema version in a capabilities or health endpoint so clients can check proactively:

json
GET /capabilities
{
  "docling_core_version": "2.86.0",
  "document_schema_version": "1.10.0",
  "min_compatible_schema_version": "1.0.0"
}

The client reads this on startup and logs a warning (or refuses to start) if the server's schema version is ahead of the client's CURRENT_VERSION.


Change classification and version-bump rules

Formalise in CONTRIBUTING.md:

ChangeSchema version bumpRequired companion work
New enum valueMinor bumpDowngrade projector mapping new value to nearest old equivalent
New optional field on existing modelMinor bumpDowngrade projector stripping the field
New required fieldMinor bumpDowngrade projector supplying a safe default
New DocItem subtype (new discriminator label)Minor bumpProjector converting to nearest existing type or stripping item
Field removed or renamedMajor bumpMigration guide; old clients must upgrade
Semantic change (same name, different meaning)Major bumpMigration guide

Summary

MechanismHandlesCostClient upgrade needed?
Tolerant Reader (extra="ignore" + enum coercion)New fields, new enum valuesLow — one-time model changeYes
Softer version validator (warn instead of raise on higher minor)Version mismatch detectionMinimalYes
Server downgrade projectorAll additive changesMedium — per-bump projector + server endpointNo — client sends version header
Capabilities endpointProactive mismatch detectionLow — server-side onlyNo — opt-in

Bottom line: The two-mechanism Tolerant Reader fix (extra="ignore" + BeforeValidator on enum fields) is sufficient for 100% of the observed breaking-change space across all 50 releases analysed. No required-field addition has ever appeared. The downgrade-projector layer additionally handles future new DocItem subtypes (4 occurrences in 50 releases) where the client truly cannot process a feature at all.

Conclusion

The following sections describe 3 possible layers (non-exclusive) for compatibility. Since we want to keep the benefits of strict validation and keep the compatibility logic in the server system, we will proceed with the implementation of the Layer 2 as remediation strategy.

As part of the solution, we will need to:

  • automate the downgrade projectors as much as possible
  • add validation scripts in the CI/CD
  • coordinate the downgrade projectors and DoclingDocument versioning with Doclang