Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
c28d041
feat: add message callable to DQRule for custom check messages
ghanse Mar 20, 2026
6721317
Refactor
ghanse Mar 21, 2026
ab9a4ff
Update documentation and tests
ghanse Mar 23, 2026
05baafb
Fix tests
ghanse Mar 23, 2026
468f229
Fix tests
ghanse Mar 23, 2026
e6fe9e5
Merge branch 'main' into ghanse/issue-958-custom-check-messages
ghanse Mar 29, 2026
0c2132f
Merge branch 'main' into ghanse/issue-958-custom-check-messages
mwojtyczka Apr 15, 2026
c08cf31
Refactor to SQL expressions for custom check messages
ghanse Apr 16, 2026
b46ca2b
Merge branch 'main' into ghanse/issue-958-custom-check-messages
mwojtyczka Apr 20, 2026
10313ce
Merge branch 'main' into ghanse/issue-958-custom-check-messages
ghanse Apr 22, 2026
e13b0ef
Merge branch 'main' into ghanse/issue-958-custom-check-messages
mwojtyczka Apr 23, 2026
580a4af
Merge branch 'main' into ghanse/issue-958-custom-check-messages
mwojtyczka Apr 24, 2026
6ec79a5
Merge branch 'main' into ghanse/issue-958-custom-check-messages
mwojtyczka Apr 28, 2026
050c1d7
Merge branch 'main' into ghanse/issue-958-custom-check-messages
mwojtyczka Apr 30, 2026
7ed09c7
Rename DQRule.message to message_expr; accept str | Column; drop plac…
ghanse May 3, 2026
52cd2df
Update docs
ghanse May 3, 2026
4560ac6
Merge branch 'main' into ghanse/issue-958-custom-check-messages
mwojtyczka May 4, 2026
f73a57d
Merge branch 'main' into ghanse/issue-958-custom-check-messages
ghanse May 30, 2026
c9a62a0
Merge branch 'main' into ghanse/issue-958-custom-check-messages
ghanse Jun 3, 2026
49c9a59
Format tests
ghanse Jun 3, 2026
237a572
Fix unit tests
ghanse Jun 4, 2026
7d2ec21
Format
ghanse Jun 4, 2026
7172765
Add validation safety
ghanse Jun 4, 2026
2891bf0
Fix expression
ghanse Jun 4, 2026
80bcd2e
Update edge case handling and widen tests
ghanse Jun 5, 2026
ec8dfd6
Add documentation of edge cases and best practices
ghanse Jun 5, 2026
ebdf61e
Improve docstring and fix tests
ghanse Jun 5, 2026
a78bd20
Note validation behavior in documentation
ghanse Jun 5, 2026
1e169ea
Merge branch 'main' into ghanse/issue-958-custom-check-messages
ghanse Jun 5, 2026
0971779
Merge branch 'main' into ghanse/issue-958-custom-check-messages
ghanse Jun 5, 2026
7170d09
Remove usage of is_sql_query_safe and add documentation
ghanse Jun 7, 2026
a950d73
Format, update docs, and update tests
ghanse Jun 7, 2026
dbf5137
Add handling for invalid message expressions
ghanse Jun 8, 2026
243002c
Handle invalid message expressions
ghanse Jun 8, 2026
0ff2415
Merge branch 'main' into ghanse/issue-958-custom-check-messages
mwojtyczka Jun 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions docs/dqx/docs/reference/quality_checks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4080,6 +4080,85 @@ Using DQX classes:
When using dataset-level checks, the top-level `filter` condition is pushed down as `row_filter` to the check function and applied before aggregation, ensuring that the check operates only on the relevant subset of rows rather than on the aggregated results.
</Admonition>

## Customizing check messages

Users can override the default failure message of any `DQRule` by specifying a custom message expression. Set `message_expr` to either a Spark SQL expression string or a Spark `Column` expression that returns a string-valued message when a check fails.

<Admonition type="tip" title="Null-safe dynamic messages">
When your expression references a column, wrap it with `coalesce` to avoid null messages. In Spark SQL, `concat(..., null)` returns `null`.
</Admonition>
Comment thread
mwojtyczka marked this conversation as resolved.

<Tabs>
<TabItem value="Python" label="Python" default>
```python
import pyspark.sql.functions as F
from databricks.labs.dqx import check_funcs
from databricks.labs.dqx.rule import DQRowRule

# static message: "Email must not be null"
checks = [
DQRowRule(
name="email_not_null",
criticality="error",
check_func=check_funcs.is_not_null,
column="email",
message_expr="'Email must not be null'",
)
]

# dynamic message using a SQL expression string: "age_positive: age <value> is not valid"
checks = [
DQRowRule(
name="age_positive",
criticality="error",
check_func=check_funcs.is_not_less_than,
column="age",
check_func_kwargs={"limit": 0},
message_expr="concat('age_positive: age ', coalesce(cast(age as string), 'null'), ' is not valid')",
)
]

# dynamic message using a Spark Column expression: "age_positive: age <value> is not valid"
checks = [
DQRowRule(
name="age_positive",
criticality="error",
check_func=check_funcs.is_not_less_than,
column="age",
check_func_kwargs={"limit": 0},
message_expr=F.concat(
F.lit("age_positive: age "),
F.coalesce(F.col("age").cast("string"), F.lit("null")),
F.lit(" is not valid"),
),
)
]
Comment thread
mwojtyczka marked this conversation as resolved.
```
</TabItem>
<TabItem value="YAML" label="YAML">
```yaml
# static message: "Email must not be null"
- name: email_not_null
criticality: error
message_expr: "'Email must not be null'"
check:
function: is_not_null
arguments:
column: email

# dynamic message using a SQL expression string: "age_positive: age <value> is not valid"
- name: age_positive
criticality: error
message_expr: "concat('age_positive: age ', coalesce(cast(age as string), 'null'), ' is not valid')"
check:
function: is_not_less_than
arguments:
column: age
limit: 0
```
</TabItem>
</Tabs>

## Converting checks between formats

In DQX, checks can be defined either as Python classes or YAML declarations. When using YAML, the files are first parsed into dictionaries and then transformed into DQX class instances under the hood. Since both formats share the same internal structure, they are interchangeable and can be safely converted between one another.
Expand Down
4 changes: 4 additions & 0 deletions src/databricks/labs/dqx/checks_serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@ def deserialize(self, checks: list[dict]) -> list[DQRule]:
criticality = check_def.get("criticality", "error")
filter_str = check_def.get("filter")
user_metadata = check_def.get("user_metadata")
message_expr = check_def.get("message_expr")
Comment on lines 269 to +270

# Exclude `column` and `columns` from check_func_kwargs
# as these are always included in the check function call
Expand All @@ -282,6 +283,7 @@ def deserialize(self, checks: list[dict]) -> list[DQRule]:
filter=filter_str,
check_func_kwargs=check_func_kwargs,
user_metadata=user_metadata,
message_expr=message_expr,
).get_rules()
else:
rule_type = CHECK_FUNC_REGISTRY.get(func_name)
Expand All @@ -296,6 +298,7 @@ def deserialize(self, checks: list[dict]) -> list[DQRule]:
criticality=criticality,
filter=filter_str,
user_metadata=user_metadata,
message_expr=message_expr,
)
)
else: # default to row-level rule
Expand All @@ -309,6 +312,7 @@ def deserialize(self, checks: list[dict]) -> list[DQRule]:
criticality=criticality,
filter=filter_str,
user_metadata=user_metadata,
message_expr=message_expr,
)
)

Expand Down
26 changes: 25 additions & 1 deletion src/databricks/labs/dqx/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,9 +151,11 @@ def _build_result_struct(self, condition: Column, skipped: bool = False) -> Colu
# or use literal run time if explicitly overridden
run_time_expr = F.current_timestamp() if self.run_time_overwrite is None else F.lit(self.run_time_overwrite)

message_col = self._build_message_col(condition)

return F.struct(
F.lit(self.check.name).alias("name"),
condition.alias("message"),
message_col.alias("message"),
self.check.columns_as_string_expr.alias("columns"),
F.lit(self.check.filter or None).cast("string").alias("filter"),
F.lit(self.check.check_func.__name__).alias("function"),
Expand All @@ -167,6 +169,28 @@ def _build_result_struct(self, condition: Column, skipped: bool = False) -> Colu
F.lit(skipped or None).alias("skipped"),
Comment thread
mwojtyczka marked this conversation as resolved.
).cast(dq_result_item_schema)

def _build_message_col(self, condition: Column) -> Column:
"""
Builds the message column, using the default message or the user-supplied
``message_expr`` from the rule definition. The expression is evaluated as-is — DQX
does not substitute placeholders. Accepts either a Spark SQL expression string or a
Spark Column.

Args:
condition: Default DQX condition message returned by evaluating the DQX check function

@mwojtyczka mwojtyczka Mar 31, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When self.check.column is None but self.check.columns is set (e.g., a multi-column row rule), column_value falls back to F.lit(None). The message function receives no useful value to display.
Either document this or pass all column values as a struct. Then always pass a struct, even for single-column rules:

  if self.check.column:                                                                                                   
      column_value = F.struct(F.col(self.check.column).cast("string").alias(self.check.column))                           
  elif self.check.columns:                                                                                                
      column_value = F.struct(*[F.col(c).cast("string").alias(c) for c in self.check.columns])                            
  else:                                                                                                                   
      column_value = F.lit(None)            

Passing as struct would be more flexible, but it will add significant complexity for users when defining the message func so maybe just document this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think we should documente only


Returns:
The custom DQX condition message if ``message_expr`` is set on the rule, otherwise the
default DQX condition message.
"""
if self.check.message_expr is None:
return condition

custom_message = (
self.check.message_expr if isinstance(self.check.message_expr, Column) else F.expr(self.check.message_expr)
Comment thread
mwojtyczka marked this conversation as resolved.
Outdated
Comment thread
mwojtyczka marked this conversation as resolved.
Outdated
Comment thread
mwojtyczka marked this conversation as resolved.
Outdated
)
return F.when(condition.isNotNull(), custom_message).otherwise(F.lit(None).cast("string"))
Comment thread
mwojtyczka marked this conversation as resolved.
Outdated
Comment thread
mwojtyczka marked this conversation as resolved.
Outdated

def _get_invalid_cols_message(self) -> str:
"""
Returns invalid columns message containing info about invalid columns to check should be applied to or filter.
Expand Down
13 changes: 13 additions & 0 deletions src/databricks/labs/dqx/rule.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,11 @@ class DQRule(abc.ABC, DQRuleTypeMixin, SingleColumnMixin, MultipleColumnsMixin):
* *check_func_args* (optional) - Positional arguments for the check function (excluding *column*).
* *check_func_kwargs* (optional) - Keyword arguments for the check function (excluding *column*).
* *user_metadata* (optional) - User-defined key-value pairs added to metadata generated by the check.
* *message_expr* (optional) - User-defined expression used as the check failure message. Accepts either
Comment thread
mwojtyczka marked this conversation as resolved.
a Spark SQL expression string or a Spark *Column* expression. The expression is evaluated as-is.
Any column references, casts, or rule-identifying literals must be supplied directly by the caller
(e.g., ``F.concat(F.lit('age_positive: value '), F.col('age').cast('string'))`` or
``"concat('age_positive: value ', cast(age as string))"``).
"""

check_func: Callable
Expand All @@ -176,6 +181,7 @@ class DQRule(abc.ABC, DQRuleTypeMixin, SingleColumnMixin, MultipleColumnsMixin):
check_func_args: list[Any] = field(default_factory=list)
check_func_kwargs: dict[str, Any] = field(default_factory=dict)
user_metadata: dict[str, str] | None = None
message_expr: str | Column | None = None
Comment thread
mwojtyczka marked this conversation as resolved.

def __post_init__(self):
Comment thread
mwojtyczka marked this conversation as resolved.
self._validate_rule_type(self.check_func)
Comment thread
mwojtyczka marked this conversation as resolved.
Expand Down Expand Up @@ -259,6 +265,10 @@ def to_dict(self) -> dict:

if self.user_metadata:
metadata["user_metadata"] = self.user_metadata
# Only string expressions can be round-tripped through metadata; Column objects are
# in-process Spark expressions with no canonical YAML/JSON representation.
if isinstance(self.message_expr, str) and self.message_expr:
metadata["message_expr"] = self.message_expr
Comment thread
mwojtyczka marked this conversation as resolved.
return metadata

def _initialize_column_if_missing(self):
Expand Down Expand Up @@ -428,6 +438,7 @@ class DQForEachColRule(DQRuleTypeMixin):
check_func_args: list[Any] = field(default_factory=list)
check_func_kwargs: dict[str, Any] = field(default_factory=dict)
user_metadata: dict[str, str] | None = None
message_expr: str | Column | None = None
Comment thread
mwojtyczka marked this conversation as resolved.

def get_rules(self) -> list[DQRule]:
"""Build a list of rules for a set of columns.
Expand All @@ -453,6 +464,7 @@ def get_rules(self) -> list[DQRule]:
criticality=self.criticality,
filter=self.filter,
user_metadata=self.user_metadata,
message_expr=self.message_expr,
)
)
else: # default to row-level rule
Expand All @@ -467,6 +479,7 @@ def get_rules(self) -> list[DQRule]:
criticality=self.criticality,
filter=self.filter,
user_metadata=self.user_metadata,
message_expr=self.message_expr,
)
)
return rules
Expand Down
Loading
Loading