Skip to content

Record Validation⚓︎

Use validate_record() for rules that need one complete parsed record.

Field parsers answer "how should this cell become a Python value?" Record validation answers "is this parsed record acceptable?" That difference matters. A parser should not need to know every other field in the row. A record validator can read self.age, self.role, self.email, defaults, references, and parse-time context together.

A user table with record rules
Given the account users exist
  | username | age | role   |
  | alice    | 34  | admin  |
  | bob      | 21  | viewer |

Use record validation after parsing

If a rule needs two or more fields from the same record, put it in validate_record(). If the rule only converts one cell, keep it as a parser.

Add a Record Validator⚓︎

Define validate_record(self, context) on the schema class. Talika calls it after the record has been parsed and populated.

A row schema with record validation
from talika import RowTable, field, integer


class AccountRows(RowTable):
    username = field("username", required=True)
    age = field("age", parser=integer())
    role = field("role", default="viewer")

    def validate_record(self, context):
        if self.age < 18:
            raise ValueError(f"{self.username} must be at least 18")
        if self.role not in context.user_data["allowed_roles"]:
            raise ValueError(f"role {self.role!r} is not allowed")

This validator checks two record-level rules:

  • a user must be at least 18
  • a user role must be part of the allowed role set passed in parse context
Parsing valid records
users = AccountRows.parse(
    [
        ["username", "age", "role"],
        ["alice", "34", "admin"],
        ["bob", "21", "viewer"],
    ],
    context={"allowed_roles": {"admin", "editor", "viewer"}},
)

assert users[0].age == 34
assert users[0].role == "admin"
assert users[1].role == "viewer"
Validated records
>> [user.as_dict() for user in users]
[
  {'username': 'alice', 'age': 34, 'role': 'admin'},
  {'username': 'bob', 'age': 21, 'role': 'viewer'},
]

The values inside validate_record() are already parsed. age is an integer because its field parser has run, and role is present even when it came from a default.

Validation returns None

A validator should return None when the record is valid. Raise an exception when the record is invalid.

Validation Runs After Parsers and Defaults⚓︎

Record validation happens after field parsing and default handling. That lets a validator work with the same values your test code will see.

Validation sees parsed and defaulted values
class ValidationOrder(RowTable):
    score = field("score", parser=integer())
    enabled = field("enabled", default=True)

    def validate_record(self, context):
        if self.enabled and self.score < context.user_data["minimum"]:
            raise ValueError(f"score must be at least {context.user_data['minimum']}")

The table below omits enabled, so the default is applied before validation:

A valid score record
record = ValidationOrder.parse(
    [
        ["score"],
        ["12"],
    ],
    context={"minimum": 10},
)[0]

assert record.score == 12
assert record.enabled is True
Validation order result
>> record.as_dict()
{'score': 12, 'enabled': True}

If the parsed score violates the context policy, the record validator raises:

A record that fails after parsing
ValidationOrder.parse(
    [
        ["score"],
        ["7"],
    ],
    context={"minimum": 10},
)
Record validation diagnostic
Record validation failed: score must be at least 10 
(code=record_validation_failed, schema=ValidationOrder, row=2)

Do not duplicate parser checks

Avoid re-parsing strings in validate_record(). If a field should be an integer, parse it as an integer on the field and let validation inspect the integer value.

Use Parse Context for Runtime Policy⚓︎

The context argument is a ParseContext. It carries the read-only user_data mapping supplied to parse(..., context={...}).

That is useful when the rule changes by test environment, fixture, tenant, scenario, or project configuration. In the user example, allowed roles are runtime policy. The schema owns the rule shape, while the parse call supplies the allowed set.

A role rejected by context policy
AccountRows.parse(
    [
        ["username", "age", "role"],
        ["maya", "25", "owner"],
    ],
    context={"allowed_roles": {"admin", "editor", "viewer"}},
)
Context policy diagnostic
Record validation failed: role 'owner' is not allowed 
(code=record_validation_failed, schema=AccountRows, row=2)

The error includes the record source row. It does not include a column because ordinary ValueError from validate_record() describes the whole record, not one specific field cell.

Pass policy, not mutable state

Prefer small context values such as allowed sets, limits, feature flags, or service handles. Keep the validator deterministic for the current parse call.

Raise Ordinary Exceptions for Record-Level Errors⚓︎

For rules that describe the whole record, raise a normal exception with a clear message.

A user below the minimum age
AccountRows.parse(
    [
        ["username", "age", "role"],
        ["kai", "16", "viewer"],
    ],
    context={"allowed_roles": {"admin", "editor", "viewer"}},
)
Record-level validation failure
Record validation failed: kai must be at least 18 
(code=record_validation_failed, schema=AccountRows, row=2)

Talika wraps the exception as record_validation_failed and attaches the record location. For row tables, that usually means the source row. For column tables, that usually means the item column and item ID.

This is enough for many validation errors. A message such as kai must be at least 18 clearly describes the record-level problem, and the diagnostic points to the row that needs attention.

The original exception is preserved

Talika keeps the original exception as the cause. The user-facing diagnostic gets table context, while debugging can still inspect the underlying exception.

Point at One Field Cell⚓︎

Sometimes a record rule should point at a specific authored cell. For example, an invalid email is a record rule because the check happens after parsing, but the fix belongs to the email cell.

Use self.source_for("field_name") with TableError.from_cell(...):

A source-aware record validator
from talika import TableError


class SourceAwareAccounts(RowTable):
    username = field("username", required=True)
    age = field("age", parser=integer())
    email = field("email", required=True)

    def validate_record(self, context):
        if "@" not in self.email:
            raise TableError.from_cell(
                "Email must contain @",
                self.source_for("email"),
                schema=type(self),
                field="email",
                hint="Use a complete email address in the table.",
            )
An invalid email cell
SourceAwareAccounts.parse(
    [
        ["username", "age", "email"],
        ["alice", "34", "not-an-email"],
    ]
)
Source-aware validation diagnostic
Email must contain @ 
(code=table_error, schema=SourceAwareAccounts, field='email', 
row=2, column=3, value='not-an-email'). 
Hint: Use a complete email address in the table.

This diagnostic points to row 2, column 3, the exact cell that supplied email. It also carries the original value and a hint for the table author.

source_for needs a source cell

source_for("email") only works when email came from the table. If the value came from a default for a missing field, there is no authored cell to point at.

Defaults May Not Have Source Cells⚓︎

Defaults are schema-owned values. When a field is missing and Talika supplies a default, there is no table cell behind that value.

Trying to locate a defaulted field
class DefaultEmail(RowTable):
    name = field("name")
    email = field("email", default="unknown")

    def validate_record(self, context):
        if self.email == "unknown":
            self.source_for("email")


DefaultEmail.parse(
    [
        ["name"],
        ["Alice"],
    ]
)
Missing source-cell diagnostic
Record validation failed: "No source cell is available for field 'email'" 
(code=record_validation_failed, schema=DefaultEmail, row=2)

In real validators, check whether the problem belongs to a source cell before calling source_for(...). If the problem belongs to a defaulted value, raise a record-level error instead.

Use the most helpful location

If one table cell caused the problem, use TableError.from_cell(...). If the whole record is invalid, raise a normal exception and let Talika report the record location.

Column Records⚓︎

Record validation works the same way for ColumnTable schemas. The record is one item column, and diagnostics identify the item column rather than a row.

A column table with one invalid item
Given the content exists
  | IDs      | A-1          | P-1        |
  | Type     | Article      | Poll       |
  | Headline | Market brief | Choose one |
Column record validation
from talika import ColumnTable, id_field


class ContentColumns(ColumnTable):
    id = id_field("IDs")
    content_type = field("Type")
    headline = field("Headline")

    def validate_record(self, context):
        if self.content_type == "Poll" and not self.headline.endswith("?"):
            raise ValueError("Poll headline must end with a question mark")

Here the rule is local to one content item: if the item is a poll, its headline should be phrased as a question.

A column item that fails validation
ContentColumns.parse(
    [
        ["IDs", "A-1", "P-1"],
        ["Type", "Article", "Poll"],
        ["Headline", "Market brief", "Choose one"],
    ]
)
Column validation diagnostic
Record validation failed: Poll headline must end with a question mark 
(code=record_validation_failed, schema=ContentColumns, column=3, item_id='P-1')

The diagnostic includes item_id='P-1' and column=3, because the invalid record is the third item column in the authored table.

Conditional Fields Validation⚓︎

Record validation is a good place for conditional field rules. These are rules where one parsed value changes what another field means.

For example, a content item may allow an empty publication date while it is a draft. Once the status becomes Published, the same empty cell is no longer valid. A field parser cannot make that decision by looking at the publication date alone; the rule needs the parsed status too.

Conditional field validation
class ArticlePublishSchema(RowTable):
    status = field("status", required=True)
    pub_date = field("publication date", default="")

    def validate_record(self, context):
        if self.status.lower() == "published" and not self.pub_date:
            raise TableError.from_cell(
                "Publication date is required when status is Published",
                self.source_for("pub_date"),
                schema=self.__class__,
                field="publication date",
            )
Parsing a record that violates the conditional rule
# This raises TableError because pub_date is blank but status is published
try:
    ArticlePublishSchema.parse(
        [
            ["status", "publication date"],
            ["published", ""],
        ]
    )
except TableError as exc:
    # Diagnostic output shown below
    print(exc)
Conditional validation error
Publication date is required when status is Published 
(code=table_error, schema=ArticlePublishSchema, field='publication date', 
row=2, column=2, value='')

The validator points to the empty publication date cell because that is the cell the author should fix. The rule is about the relationship between two fields, but the diagnostic should still land on the most actionable source cell.

Point to the field that needs action

Conditional validation often reads two or three fields. When it fails, choose the source cell that the feature author should edit first.

Keep Record Validation Local⚓︎

Record validation should only inspect self and parse context. If a rule needs to compare multiple records, such as duplicate emails or "at least one primary user", it belongs in whole-table validation.

Good record-validation rules include:

  • age must be at least 18 for this user
  • a poll headline must end with a question mark
  • an email cell must contain @
  • a role must be allowed for this parse context
  • a start date must be before an end date on the same record

Rules that usually do not belong in validate_record() include:

  • no two records may share the same email
  • at least one record must be primary
  • every child record must refer to a parent record in another row
  • totals across all records must balance

Validate at the smallest useful scope

Put single-cell syntax in parsers, one-record rules in validate_record(), and cross-record rules in table validation. That keeps failures easier to explain and easier to locate.