ASSERTIONS
Schema Validation
Validate complex API payloads using Karate's schema validation - simpler and more powerful than JSON-schema, with zero dependencies and inline assertions in a single step.
On this page:
- Validation markers - Type checking with
#string,#number,#boolean, etc. - Optional fields - Handle missing keys with
##prefix - Array validation - Size and element type checks with
#[] - Self-validation - Custom logic with
#? expression - Reusable schemas - Define once, use everywhere
- Cross-field validation - Validate related fields together
Validation Markers
Karate provides built-in markers for type and format validation:
| Marker | Description |
|---|---|
#ignore | Skip comparison for this field |
#null | Value must be null (key must be present) |
#notnull | Value must not be null |
#present | Key must be present (value can be anything, even null) |
#notpresent | Key must not be present at all |
#array | Value must be a JSON array |
#object | Value must be a JSON object |
#boolean | Value must be true or false |
#number | Value must be a number |
#string | Value must be a string |
#uuid | Value must match UUID format |
#regex STR | Value must match the regular expression |
#? EXPR | JavaScript expression must evaluate to true |
Feature: Validation markers
Scenario: Basic type validation
Given url 'https://jsonplaceholder.typicode.com'
And path 'users', 1
When method get
Then status 200
And match response ==
"""
{
id: '#number',
name: '#string',
username: '#string',
email: '#regex .+@.+',
address: '#object',
phone: '#string',
website: '#string',
company: '#object'
}
"""
Use double backslash for regex escaping: '#regex a\\.dot' matches 'a.dot'
Optional Fields
Prefix any marker with ## to make a field optional (key can be missing or value can be null):
Feature: Optional field validation
Scenario: Schema with optional fields
* def user = { id: 1, name: 'John', email: 'john@test.com' }
# middleName is optional - can be missing or null
* match user == { id: '#number', name: '#string', email: '#string', middleName: '##string' }
Null vs Not Present
Karate distinguishes between a key with null value and a missing key:
Feature: Null handling
Scenario: Understand null vs notpresent
* def withNull = { a: null }
* def empty = { }
# Key exists with null value
* match withNull == { a: '#null' }
* match withNull == { a: '#present' }
# Key does not exist
* match empty == { a: '#notpresent' }
* match empty == { a: '##null' }
# ##null matches both cases
* match withNull == { a: '##null' }
* match empty == { a: '##null' }
Array Validation
Use #[] for array validation with optional size and element type:
Feature: Array validation
Scenario: Array size and type patterns
Given url 'https://jsonplaceholder.typicode.com'
And path 'users'
When method get
Then status 200
# Must be an array (any size)
* match response == '#[]'
# Must be an array of exactly 10 items
* match response == '#[10]'
# Must be an array of objects
* match response == '#[] #object'
Array Element Validation
Combine array markers with element type validation:
Feature: Array element types
Scenario: Validate array element types
* def tags = ['api', 'test', 'karate']
# Array of strings
* match tags == '#[] #string'
# Array of exactly 3 strings
* match tags == '#[3] #string'
# Each string has length > 0
* match tags == '#[] #string? _.length > 0'
Match Each
Use match each to validate every element in an array against a schema:
Feature: Match each validation
Scenario: Validate each array element
Given url 'https://jsonplaceholder.typicode.com'
And path 'users'
When method get
Then status 200
And match each response ==
"""
{
id: '#number',
name: '#string',
username: '#string',
email: '#regex .+@.+',
address: '#object',
phone: '#string',
website: '#string',
company: '#object'
}
"""
Self-Validation Expressions
Use #? EXPR for custom validation logic. The underscore _ represents the current value:
Feature: Self-validation
Scenario: Custom validation with underscore
* def user = { name: 'John', age: 25 }
# Validate age is between 18 and 100
* match user == { name: '#string', age: '#? _ >= 18 && _ <= 100' }
# Combine type check with validation
* match user == { name: '#string? _.length > 0', age: '#number? _ > 0' }
Using Functions
Create reusable validation functions:
Feature: Function validation
Scenario: Validate with functions
* def isValidEmail = function(x) { return x.indexOf('@') > 0 }
* def user = { email: 'test@example.com' }
* match user == { email: '#? isValidEmail(_)' }
Using Variables in Expressions
Reference other variables in validation expressions:
Feature: Variable references
Scenario: Use variables in validation
* def minAge = 18
* def maxAge = 100
* def user = { age: 25 }
* match user == { age: '#? _ >= minAge && _ <= maxAge' }
Cross-Field Validation
Use $ to reference the JSON root for cross-field validation:
Feature: Cross-field validation
Scenario: Validate related fields
* def temperature = { celsius: 100, fahrenheit: 212 }
# Validate fahrenheit equals celsius * 1.8 + 32
* match temperature == { celsius: '#number', fahrenheit: '#? _ == $.celsius * 1.8 + 32' }
Match Each with Parent Reference
In match each, use _$ to reference the current array element:
Feature: Parent reference in match each
Scenario: Cross-field validation in arrays
* def orders =
"""
[
{ items: 3, total: 30, pricePerItem: 10 },
{ items: 5, total: 25, pricePerItem: 5 }
]
"""
# Validate total equals items * pricePerItem
* match each orders contains { total: '#? _ == _$.items * _$.pricePerItem' }
| Symbol | Meaning |
|---|---|
$ | The JSON root document |
_ | The current value being validated ("self") |
_$ | The parent object in match each iterations |
Reusable Schemas
Define schemas as variables and reuse them across scenarios:
Feature: Reusable schemas
Background:
* def geoSchema = { lat: '#string', lng: '#string' }
* def addressSchema = { street: '#string', suite: '#string', city: '#string', zipcode: '#string', geo: '#(geoSchema)' }
* def companySchema = { name: '#string', catchPhrase: '#string', bs: '#string' }
* def userSchema =
"""
{
id: '#number',
name: '#string',
username: '#string',
email: '#regex .+@.+',
address: '#(addressSchema)',
phone: '#string',
website: '#string',
company: '#(companySchema)'
}
"""
Scenario: Apply reusable schema
Given url 'https://jsonplaceholder.typicode.com'
And path 'users', 1
When method get
Then status 200
And match response == userSchema
Embedded Schema References
Compose larger schemas out of smaller ones. There are two distinct forms — pick based on whether the field holds a single value or an array:
- Single value — embed a schema with
#(schemaName). - Array of values — validate every element with the
#[] schemaNameshortcut. The schema name is written bare after#[], not wrapped in#(...).
Feature: Embedded schemas
Scenario: Nested schema references
* def itemSchema = { id: '#number', name: '#string' }
* def addressSchema = { city: '#string', zip: '#string' }
* def orderSchema =
"""
{
orderId: '#number',
shipTo: '#(addressSchema)',
items: '#[] itemSchema'
}
"""
* def order = { orderId: 1, shipTo: { city: 'NYC', zip: '10001' }, items: [{ id: 1, name: 'Widget' }] }
* match order == orderSchema
#[] takes a bare schema nameWrite items: '#[] itemSchema' — not items: '#[] #(itemSchema)'. After the #[] array marker the remainder is already evaluated as the per-element schema, so wrapping it in #(...) is unnecessary and can misfire. Reserve #(...) for embedding a schema as a single value, like shipTo: '#(addressSchema)'.
Optional Nested Schemas
A nested object that may legitimately be null or absent — common for fields gated on a user's role or account type — takes the ## prefix, just like ##string does for a primitive. Write it inline on the right-hand side of the match:
Feature: Optional nested schema
Scenario: A nullable nested object
* def detailsSchema = { name: '#string', id: '#number' }
# all three pass — null, absent, or a value that matches
* match { status: 'active', details: null } == { status: '#string', details: '##(detailsSchema)' }
* match { status: 'active' } == { status: '#string', details: '##(detailsSchema)' }
* match { status: 'active', details: { name: 'x', id: 1 } } == { status: '#string', details: '##(detailsSchema)' }
## only excuses null and a missing key. A details that is present still has to match detailsSchema.
( ) to re-use it##(...) does double duty. When you are building data, it means "substitute this value, and drop the key if it is null" — so a plain def resolves the reference and inlines the schema, which quietly turns the field back into a required one:
# WRONG - '##(detailsSchema)' is inlined at def time, the '##' is lost
* def mainSchema = { status: '#string', details: '##(detailsSchema)' }
* match { status: 'active', details: null } == mainSchema # fails
Wrap the JSON in round brackets to keep the placeholder intact until the match:
# RIGHT - the ( ) preserve '##(detailsSchema)' for the match engine
* def mainSchema = ({ status: '#string', details: '##(detailsSchema)' })
* match { status: 'active', details: null } == mainSchema # passes
This applies only to ##(schemaName). Required references such as '#(addressSchema)' and array shortcuts such as '#[] itemSchema' need no brackets — see Schema as a Reusable Template below.
For XML, the equivalent is to load the schema as text first and convert it with xml — which likewise skips the substitution that a def would do:
* def subSchema = <c>#string</c>
* text schema =
"""
<root>
<a>#string</a>
<b>##(subSchema)</b>
</root>
"""
* xml schema = schema
* match <root><a>x</a></root> == schema
If the schema lives in its own file, see Cross-Referenced Schema Files — read() is a data-building path, so it inlines ##(...) just like a plain def does.
Schema as a Reusable Template
A #(...) placeholder inside a schema doesn't have to be resolvable when the schema is defined. If the referenced variable isn't in scope yet at def time, Karate preserves the placeholder and resolves it later — when the schema is actually used in a match. This lets a shared schema (in a file, or a Background) be loaded before the caller supplies the values it depends on:
Feature: Schema as a template
Scenario: Placeholder resolved at match time
# idCheck isn't defined yet — the #(idCheck) placeholder is preserved, not an error
* def userSchema =
"""
{
id: '#(idCheck)',
name: '#string'
}
"""
# the caller decides what 'id' must look like, later
* def idCheck = '#number'
* def user = { id: 42, name: 'Alice' }
* match user == userSchema
If the placeholder is still unresolved when the match runs, the error surfaces there — at match time — rather than being silently swallowed.
External Schema Files
Load schemas from JSON files for larger projects:
Feature: External schemas
Scenario: Load schema from file
* def userSchema = read('classpath:schemas/user-schema.json')
Given url 'https://jsonplaceholder.typicode.com'
And path 'users', 1
When method get
Then status 200
And match response == userSchema
Cross-Referenced Schema Files
Once schemas live in files, one of them usually references another — an entity schema whose address is described by an address schema. A required reference works with a plain read(), because inlining the nested schema is exactly what you want:
{
"id": "#string",
"address": "#(addressSchema)"
}
* def addressSchema = read('matchers/address.json')
* def entitySchema = read('matchers/entity.json')
An optional reference — ##(addressSchema), for an address that may be null or absent — is different. read() is a data-building path, so it resolves the reference and inlines the nested schema, and the ## is consumed along the way; address ends up plain required. It is the same trap as a bracket-less def, described under Optional Nested Schemas above.
To keep the marker intact, read the file as raw text with karate.readAsString() and convert it with the json keyword. json parses the text exactly as authored — it does not resolve embedded expressions — so ##(addressSchema) survives to the match engine:
{
"address1": "#string",
"address2": "##string",
"city": "#string",
"postalCode": "#string"
}
{
"id": "#string",
"address": "##(addressSchema)"
}
Feature: Cross-referenced schema files
Background:
* def addressSchema = read('matchers/address.json')
* json entitySchema = karate.readAsString('matchers/entity.json')
Scenario: address may be null, absent, or a full object
* match { id: 'x', address: null } == entitySchema
* match { id: 'x' } == entitySchema
* match { id: 'x', address: { address1: 'a', city: 'c', postalCode: 'p' } } == entitySchema
This is the JSON twin of the text then xml idiom shown for XML schemas above.
Because the reference is resolved at match time, addressSchema only has to be in scope when the match runs. The two Background lines can be in either order, and a schema can even be loaded before the feature that supplies its dependencies. If the nested schema was never defined at all, the failure surfaces at the match and names the variable.
Mixing the two is fine, and often what you want: use read() for schema files that only contain required references, and the readAsString() + json pair for the ones carrying an optional ##(...) reference.
Contains Short-Cuts
Use these symbols in embedded expressions for contains-style matching:
| Symbol | Equivalent |
|---|---|
^ | contains |
^^ | contains only |
^* | contains any |
^+ | contains deep |
!^ | not contains |
Feature: Contains short-cuts
Scenario: Use contains in schema validation
* def cat =
"""
{
name: 'Billie',
kittens: [
{ id: 23, name: 'Bob' },
{ id: 42, name: 'Wild' }
]
}
"""
* def expected = [{ id: 42, name: 'Wild' }, { id: 23, name: 'Bob' }]
# Validate kittens contains all expected items in any order
* match cat == { name: 'Billie', kittens: '#(^^expected)' }
Complete Schema Example
Combine all techniques for comprehensive validation:
Feature: Complete schema validation
Scenario: Validate complex API response
Given url 'https://jsonplaceholder.typicode.com'
And path 'users', 1
When method get
Then status 200
And match response ==
"""
{
id: '#number',
name: '#string',
username: '#string? _.length >= 3',
email: '#regex .+@.+\\..+',
address: {
street: '#string',
suite: '#string',
city: '#string',
zipcode: '#regex \\d{5}-\\d{4}',
geo: {
lat: '#string',
lng: '#string'
}
},
phone: '#string',
website: '#string',
company: {
name: '#string',
catchPhrase: '#string',
bs: '#string'
}
}
"""
Next Steps
- Fuzzy Matching - Deep dive into validation markers
- Match Keyword - Core match operations
- Response Validation - Apply schemas to API responses