Skip to content

Table Validation⚓︎

Use validate_records() for rules that need the whole parsed table.

Record validation checks one record at a time. Table validation checks the collection: duplicates, required combinations, aggregate limits, ordering, cross-record relationships, and policy that only makes sense after every record has been parsed.

A roster with a whole-table rule
Given the user roster exists
  | email         | role   | primary |
  | a@example.com | admin  | true    |
  | b@example.com | viewer | false   |

Use the smallest useful validation scope

Put one-cell syntax in parsers, one-record rules in validate_record(), and cross-record rules in validate_records(). The smaller the scope, the easier the failure is to explain.

Add a Table Validator⚓︎

Define validate_records(cls, records, context) as a class method on the schema. Talika calls it after records are parsed, after local references are resolved, and after each record has passed validate_record().

A row schema with table validation
from talika import RowTable, TableError, boolean, field


class UserRoster(RowTable):
    email = field("email", required=True)
    role = field("role", default="viewer")
    primary = field("primary", parser=boolean(), default=False)

    @classmethod
    def validate_records(cls, records, context):
        seen = {}
        for record in records:
            if record.email in seen:
                raise TableError.from_cell(
                    "Duplicate email",
                    record.source_for("email"),
                    schema=cls,
                    field="email",
                    hint="Each user row must use a unique email address.",
                )
            seen[record.email] = record

        if not any(record.primary for record in records):
            raise ValueError("At least one primary user is required")

        domain = context.user_data["email_domain"]
        for record in records:
            if not record.email.endswith(f"@{domain}"):
                raise TableError.from_cell(
                    f"Email must belong to {domain}",
                    record.source_for("email"),
                    schema=cls,
                    field="email",
                )

This table validator checks three whole-table rules:

  • no two records may share the same email
  • at least one user must be marked primary
  • every email must belong to the configured domain
Parsing a valid roster
users = UserRoster.parse(
    [
        ["email", "role", "primary"],
        ["a@example.com", "admin", "true"],
        ["b@example.com", "viewer", "false"],
    ],
    context={"email_domain": "example.com"},
)

assert users[0].primary is True
assert users[1].primary is False
Validated table records
>> [user.as_dict() for user in users]
[
  {'email': 'a@example.com', 'role': 'admin', 'primary': True},
  {'email': 'b@example.com', 'role': 'viewer', 'primary': False},
]

The records argument contains parsed schema records. Field parsers and defaults have already run, so primary is a boolean and role may come from a default.

The hook validates the collection

validate_records() should return None when the table is valid. Raise an exception when the collection is invalid.

Use Parse Context for Table Policy⚓︎

The context argument is the same parse context used by parsers, defaults, and record validators. It carries read-only user_data from the parse call.

A table validator reading context
class SeenContext(RowTable):
    email = field("email")
    seen = None

    @classmethod
    def validate_records(cls, records, context):
        cls.seen = {
            "emails": [record.email for record in records],
            "domain": context.user_data["domain"],
        }
Parsing with context
SeenContext.parse(
    [
        ["email"],
        ["a@example.com"],
        ["b@example.com"],
    ],
    context={"domain": "example.com"},
)

assert SeenContext.seen == {
    "emails": ["a@example.com", "b@example.com"],
    "domain": "example.com",
}
Context seen by table validation
>> SeenContext.seen
{'emails': ['a@example.com', 'b@example.com'], 'domain': 'example.com'}

Use context for policies that change by test setup: allowed domains, minimum counts, publication limits, scenario mode, known external IDs, or service objects used by validation.

Keep context explicit

Avoid reading mutable global state inside table validation. Passing policy through parse(..., context={...}) makes the rule visible at the call site and keeps tests easier to reason about.

Detect Duplicates with Source-Aware Errors⚓︎

Duplicate checks are a common table-level rule. The validator must remember what it has already seen, then report the later cell that introduced the duplicate.

A duplicate email
UserRoster.parse(
    [
        ["email", "primary"],
        ["a@example.com", "true"],
        ["a@example.com", "false"],
    ],
    context={"email_domain": "example.com"},
)
Duplicate diagnostic
Duplicate email 
(code=table_error, schema=UserRoster, field='email', 
row=3, column=1, value='a@example.com'). 
Hint: Each user row must use a unique email address.

The diagnostic points to row 3, column 1, because that is the second occurrence of the duplicate email. The first occurrence is useful for comparison, but the second occurrence is the cell the author usually changes.

Point at the actionable cell

When a table-level rule can identify one offending cell, use TableError.from_cell(...) with record.source_for("field_name"). That gives the author a precise place to edit.

Raise Plain Errors for Whole-Table Problems⚓︎

Some table rules do not belong to one cell. For example, "at least one primary user is required" is a property of the collection.

A table with no primary user
UserRoster.parse(
    [
        ["email", "primary"],
        ["a@example.com", "false"],
        ["b@example.com", "false"],
    ],
    context={"email_domain": "example.com"},
)
Whole-table validation diagnostic
Table validation failed: At least one primary user is required 
(code=table_validation_failed, schema=UserRoster)

Talika wraps ordinary exceptions as table_validation_failed. The diagnostic names the schema, but it does not claim a row or column because no single cell caused the problem.

A plain table validation exception
class PlainTableValidation(RowTable):
    email = field("email")

    @classmethod
    def validate_records(cls, records, context):
        raise ValueError("table policy unavailable")


PlainTableValidation.parse(
    [
        ["email"],
        ["a@example.com"],
    ]
)
Plain exception wrapper
Table validation failed: table policy unavailable 
(code=table_validation_failed, schema=PlainTableValidation)

Use ordinary exceptions for collection-level failures

If the problem is "the table as a whole does not satisfy this rule", raise a normal exception with a clear message. Let Talika wrap it as a table validation failure.

Validate Policy Against Specific Cells⚓︎

Sometimes a policy is table-wide but the failure still belongs to one cell. In the roster example, the allowed email domain comes from parse context and applies to every record. The failing value is still the email cell.

A domain policy failure
UserRoster.parse(
    [
        ["email", "primary"],
        ["a@other.test", "true"],
    ],
    context={"email_domain": "example.com"},
)
Domain policy diagnostic
Email must belong to example.com 
(code=table_error, schema=UserRoster, field='email', 
row=2, column=1, value='a@other.test')

This pattern is useful when a shared rule scans all records but can point to the exact value that broke the rule. Use TableError.from_cell(...) for the specific cell and keep the message focused on the policy.

Do not hide table policy inside parsers

A parser should not need to know every other record. If the rule needs the collection or a configured table policy, keep it in validate_records().

Column Table Validation⚓︎

validate_records() works the same way for ColumnTable. The records are still a sequence of parsed schema records, but each record came from an item column.

A column table with too many published items
Given the content schedule exists
  | IDs     | A-1     | P-1  |
  | Type    | Article | Poll |
  | Publish | true    | true |
Column table aggregate rule
from talika import ColumnTable, id_field


class ContentSchedule(ColumnTable):
    id = id_field("IDs")
    content_type = field("Type")
    publish = field("Publish", parser=boolean(), default=False)

    @classmethod
    def validate_records(cls, records, context):
        published = [record for record in records if record.publish]
        limit = context.user_data["publish_limit"]
        if len(published) > limit:
            extra = published[limit]
            raise TableError.from_cell(
                f"Only {limit} item may be published in this scenario",
                extra.source_for("publish"),
                schema=cls,
                field="Publish",
                item_id=extra.id,
            )

This validator enforces a table-level publication limit. The rule needs all records because one item being published is valid, but two published items break the scenario policy.

A column table that breaks the limit
ContentSchedule.parse(
    [
        ["IDs", "A-1", "P-1"],
        ["Type", "Article", "Poll"],
        ["Publish", "true", "true"],
    ],
    context={"publish_limit": 1},
)
Column table validation diagnostic
Only 1 item may be published in this scenario 
(code=table_error, schema=ContentSchedule, field='Publish', 
row=3, column=3, item_id='P-1', value='true')

The diagnostic points to the second published item. It includes both item_id='P-1' and the source row/column for that item's Publish cell.

Records are orientation-neutral

Inside validate_records(), row and column records are both normal schema records. The main difference is the source metadata attached to each record.

Cross-Record Reference Checks⚓︎

A common use case for whole-table validation is relational integrity between records. For example, an organizational table may have a manager id field that should point to another record's user id.

This is not a field parser problem. The manager id cell can be syntactically valid and still refer to a user that does not exist. It is also not a single-record validation problem, because one record cannot know every ID in the table.

Because table validation runs after all records have been parsed, it can collect the valid IDs first and then check every reference against that collection.

Validating references across records
class OrgChart(RowTable):
    user_id = id_field("user id")
    manager_id = field("manager id", default="")

    @classmethod
    def validate_records(cls, records, context):
        all_ids = {r.user_id for r in records}
        for r in records:
            if r.manager_id and r.manager_id not in all_ids:
                raise TableError.from_cell(
                    f"Manager {r.manager_id} not found in org chart",
                    r.source_for("manager_id"),
                    schema=cls,
                    field="manager id",
                    item_id=r.user_id,
                )
Parsing a table with a broken reference
try:
    OrgChart.parse(
        [
            ["user id", "manager id"],
            ["U-1", ""],
            ["U-2", "U-999"],
        ]
    )
except TableError as exc:
    print(exc)
Broken reference validation error
Manager U-999 not found in org chart 
(code=table_error, schema=OrgChart, field='manager id', 
row=3, column=2, item_id='U-2', value='U-999')

The validator should point to the referencing cell, not the missing target. In this example, the authored manager id is the value the feature author can change, so the diagnostic belongs there.

References can be checked before test setup

Whole-table validation lets you reject broken relationships before the test creates users, content, or other domain objects from the parsed records.

Choose Table Validation Deliberately⚓︎

Table validation is powerful because it can inspect everything. That also makes it easy to put too much logic there. Keep it for rules that genuinely need the collection.

Good table-validation rules include:

  • no duplicate emails
  • at least one primary user exists
  • no more than one item is published in a scenario
  • start and end rows form a complete set
  • every record belongs to a configured domain
  • totals across all rows balance

Rules that usually belong elsewhere include:

  • one cell must parse as an integer
  • one user's age must be at least 18
  • one email must contain @
  • one status token must be recognized
  • one field should default when omitted

Make the failing scope match the rule

If the rule is about one cell, use a parser. If it is about one record, use record validation. If it is about the collection, use table validation.