Skip to content

Error Messages — User-Friendly Errors, Localization, and Standards

DodaTech Updated 2026-06-28 9 min read

In this tutorial, you'll learn about Error Messages. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.

Error messages communicate validation failures to users and clients. Well-designed errors are clear, actionable, consistent, and localizable — poor errors frustrate users and slow development.

What You'll Learn

By the end of this lesson, you will write user-friendly error messages, implement localization, design consistent error formats, and follow RFC 7807 Problem Details standards.

Why It Matters

A confusing error message causes support tickets, developer frustration, and lost users. A clear error message tells the user exactly what to fix and how. Error format consistency also enables automated client-side handling.

Real-World Use

Doda Browser's API returns RFC 7807 Problem Details for all validation errors, enabling its mobile clients to parse and display field-level errors in the user's language automatically.

Error Message Flow

flowchart LR
    Input[User Input] --> Validate[Validate]
    Validate --> Pass{Valid?}
    Pass -->|Yes| Process[Process Request]
    Pass -->|No| ErrorGen[Generate Error]
    ErrorGen --> Format{Format Type}
    Format -->|Simple| Simple[Field + Message]
    Format -->|RFC 7807| RFC[Problem Details]
    Format -->|Localized| L10n[Translate Messages]
    Simple --> Response[API Response]
    RFC --> Response
    L10n --> Response

User-Friendly Error Builder

# error_builder.py
from typing import Any, Dict, List, Optional

class ErrorBuilder:
    @staticmethod
    def friendly_message(field: str, code: str) -> str:
        messages = {
            "required": f"{field} is required. Please fill in this field.",
            "type_error": f"{field} has the wrong type. Check the expected format.",
            "min_length": f"{field} is too short. Make it longer.",
            "max_length": f"{field} is too long. Shorten the value.",
            "min_value": f"{field} is too low. Increase the value.",
            "max_value": f"{field} is too high. Decrease the value.",
            "email": f"{field} does not look like a valid email address.",
            "pattern": f"{field} has an invalid format. Check the requirements.",
            "unique": f"{field} is already taken. Choose a different value.",
            "password_mismatch": "Passwords do not match. Retype both carefully.",
        }
        return messages.get(code, f"{field} is invalid.")

    @staticmethod
    def build_error(field: str, code: str, detail: Optional[str] = None) -> Dict:
        return {
            "field": field,
            "code": code,
            "message": detail or ErrorBuilder.friendly_message(field, code),
        }

    @staticmethod
    def build_errors(errors: List[Dict]) -> Dict:
        return {
            "valid": len(errors) == 0,
            "count": len(errors),
            "errors": errors,
        }

vb = ErrorBuilder()
errors = [
    vb.build_error("email", "required"),
    vb.build_error("password", "min_length", "Password must be at least 8 characters"),
    vb.build_error("confirm_password", "password_mismatch"),
]

result = vb.build_errors(errors)
print(f"Valid: {result['valid']}")
print(f"Count: {result['count']}")
for err in result["errors"]:
    print(f"  [{err['code']}] {err['field']}: {err['message']}")

Expected output:

Valid: False
Count: 3
  [required] email: email is required. Please fill in this field.
  [min_length] password: Password must be at least 8 characters
  [password_mismatch] confirm_password: Passwords do not match. Retype both carefully.

RFC 7807 Problem Details

# rfc7807_errors.py
from typing import Any, Dict, List, Optional
from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone

@dataclass
class ValidationProblem:
    type: str = "about:blank"
    title: str = "Validation Error"
    status: int = 422
    detail: str = "One or more fields failed validation"
    instance: Optional[str] = None
    errors: List[Dict] = field(default_factory=list)

    def to_dict(self) -> Dict:
        result = {
            "type": self.type,
            "title": self.title,
            "status": self.status,
            "detail": self.detail,
            "errors": self.errors,
            "timestamp": datetime.now(timezone.utc).isoformat(),
        }
        if self.instance:
            result["instance"] = self.instance
        return result

def build_rfc7807_response(errors: List[Dict], instance: Optional[str] = None) -> Dict:
    problem = ValidationProblem(
        type="https://api.example.com/errors/validation",
        title="Validation Failed",
        status=422,
        detail=f"The request contains {len(errors)} validation error(s)",
        instance=instance,
        errors=errors,
    )
    return problem.to_dict()

rfc_errors = [
    {"field": "email", "code": "format", "message": "Must be a valid email address"},
    {"field": "age", "code": "range", "message": "Age must be between 13 and 150"},
]

response = build_rfc7807_response(rfc_errors, "/api/users")
print(f"Status: {response['status']}")
print(f"Title: {response['title']}")
print(f"Detail: {response['detail']}")
print(f"Instance: {response['instance']}")
for err in response["errors"]:
    print(f"  {err['field']}: {err['message']}")

Expected output:

Status: 422
Title: Validation Failed
Detail: The request contains 2 validation error(s)
Instance: /api/users
  email: Must be a valid email address
  age: Age must be between 13 and 150

Localization (i18n) System

# localization.py
from typing import Dict, List, Optional

class LocalizationEngine:
    def __init__(self):
        self.messages: Dict[str, Dict[str, str]] = {}
        self.fallback_locale = "en"

    def load(self, locale: str, messages: Dict[str, str]):
        self.messages[locale] = messages

    def translate(self, code: str, locale: str = "en", **kwargs) -> str:
        locale_messages = self.messages.get(locale, self.messages.get(self.fallback_locale, {}))
        template = locale_messages.get(code, locale_messages.get("fallback", "Validation error"))
        try:
            return template.format(**kwargs)
        except KeyError:
            return template

    def localize_error(self, error: Dict, locale: str = "en") -> Dict:
        localized = dict(error)
        code = error.get("code", "unknown")
        field = error.get("field", "field")
        localized["message"] = self.translate(code, locale, field=field)
        return localized

engine = LocalizationEngine()

engine.load("en", {
    "required": "{field} is required",
    "min_length": "{field} must be at least {min} characters",
    "email": "{field} must be a valid email",
    "fallback": "Invalid value for {field}",
})

engine.load("es", {
    "required": "{field} es obligatorio",
    "min_length": "{field} debe tener al menos {min} caracteres",
    "email": "{field} debe ser un correo valido",
    "fallback": "Valor invalido para {field}",
})

engine.load("fr", {
    "required": "{field} est requis",
    "min_length": "{field} doit contenir au moins {min} caracteres",
    "email": "{field} doit etre un email valide",
    "fallback": "Valeur invalide pour {field}",
})

errors = [
    {"field": "email", "code": "required"},
    {"field": "password", "code": "min_length", "min": 8},
]

for locale in ["en", "es", "fr"]:
    print(f"\nLocale: {locale}")
    for err in errors:
        localized = engine.localize_error(err, locale)
        print(f"  {localized['field']}: {localized['message']}")

Expected output:

Locale: en
  email: email is required
  password: password must be at least 8 characters

Locale: es
  email: email es obligatorio
  password: password debe tener al menos 8 caracteres

Locale: fr
  email: email est requis
  password: password doit contenir au moins 8 caracteres

Field Mapping for Nested Errors

# field_mapping.py
from typing import Any, Dict, List, Optional

class FieldMapper:
    def __init__(self):
        self.mappings: Dict[str, str] = {}

    def map(self, internal: str, external: str):
        self.mappings[internal] = external

    def to_external(self, internal_field: str) -> str:
        return self.mappings.get(internal_field, internal_field)

    def remap_errors(self, errors: List[Dict]) -> List[Dict]:
        remapped = []
        for err in errors:
            new_err = dict(err)
            new_err["field"] = self.to_external(err.get("field", ""))
            remapped.append(new_err)
        return remapped

    @staticmethod
    def flatten_nested(data: Dict, prefix: str = "") -> List[Dict]:
        errors = []
        for key, value in data.items():
            field_name = f"{prefix}.{key}" if prefix else key
            if isinstance(value, dict):
                if "message" in value or "code" in value:
                    errors.append({"field": field_name, **value})
                else:
                    errors.extend(FieldMapper.flatten_nested(value, field_name))
            elif isinstance(value, list):
                for i, item in enumerate(value):
                    nested_field = f"{field_name}[{i}]"
                    if isinstance(item, dict):
                        errors.extend(FieldMapper.flatten_nested(item, nested_field))
                    else:
                        errors.append({"field": nested_field, "message": str(item)})
            else:
                errors.append({"field": field_name, "message": str(value)})
        return errors

mapper = FieldMapper()
mapper.map("user.email", "email_address")
mapper.map("user.pass", "password")

raw_errors = [
    {"field": "user.email", "code": "required"},
    {"field": "user.pass", "code": "min_length", "message": "Password too short"},
]

remapped = mapper.remap_errors(raw_errors)
for err in remapped:
    print(f"  {err['field']}: {err.get('message', err['code'])}")

nested = {
    "user": {
        "name": {"code": "required"},
        "address": {
            "street": {"code": "required"},
            "city": {"code": "required"},
        }
    }
}
flat = FieldMapper.flatten_nested(nested)
print("\nFlattened nested errors:")
for err in flat:
    print(f"  {err['field']}: {err['code']}")

Expected output:

  email_address: required
  password: Password too short

Flattened nested errors:
  user.name: required
  user.address.street: required
  user.address.city: required

Error Response Standards

# error_standards.py
from typing import Any, Dict, List, Optional

class ErrorResponseFormatter:
    @staticmethod
    def simple(errors: List[Dict]) -> Dict:
        return {
            "success": False,
            "error": {
                "message": "Validation failed",
                "details": errors,
            }
        }

    @staticmethod
    def flat(errors: List[Dict]) -> Dict:
        result = {"success": False, "errors": {}}
        for err in errors:
            field = err.get("field", "general")
            result["errors"][field] = err.get("message", str(err))
        return result

    @staticmethod
    def array(errors: List[Dict]) -> Dict:
        return {
            "success": False,
            "errors": [{"field": e.get("field"), "message": e.get("message")} for e in errors]
        }

    @staticmethod
    def graphql_format(errors: List[Dict]) -> List[Dict]:
        return [
            {
                "message": e.get("message", "Validation error"),
                "extensions": {
                    "code": e.get("code", "VALIDATION_ERROR"),
                    "field": e.get("field"),
                }
            }
            for e in errors
        ]

fmt = ErrorResponseFormatter()
sample_errors = [
    {"field": "email", "code": "required", "message": "Email is required"},
    {"field": "password", "code": "min_length", "message": "Minimum 8 characters"},
]

print("Simple format:")
print(f"  {fmt.simple(sample_errors)}")

print("Flat format:")
print(f"  {fmt.flat(sample_errors)}")

print("Array format:")
print(f"  {fmt.array(sample_errors)}")

print("GraphQL format:")
for err in fmt.graphql_format(sample_errors):
    print(f"  {err}")

Expected output:

Simple format:
  {'success': False, 'error': {'message': 'Validation failed', 'details': [{'field': 'email', 'code': 'required', 'message': 'Email is required'}, {'field': 'password', 'code': 'min_length', 'message': 'Minimum 8 characters'}]}}
Flat format:
  {'success': False, 'errors': {'email': 'Email is required', 'password': 'Minimum 8 characters'}}
Array format:
  {'success': False, 'errors': [{'field': 'email', 'message': 'Email is required'}, {'field': 'password', 'message': 'Minimum 8 characters'}]}
GraphQL format:
  {'message': 'Email is required', 'extensions': {'code': 'required', 'field': 'email'}}
  {'message': 'Minimum 8 characters', 'extensions': {'code': 'min_length', 'field': 'password'}}

Common Mistakes

1. Technical Jargon in Messages

Messages like "Field 'email' failed regex pattern validation" are useless to end users. Write "Enter a valid email address."

2. Inconsistent Error Format

One endpoint returns {errors: [{field, message}]}, another returns {error: "msg"}. Standardize across your API.

3. Exposing Internal Field Names

Sending user.profile.account.email as the field path leaks schema details. Map to external names like email_address.

4. No Error Codes

Clients need stable error codes (like min_length) to handle errors programmatically. Parsing message strings is fragile.

5. Not Localizing at All

Returning English-only errors excludes non-English-speaking users. Support at least a locale query parameter.

Practice Questions

1. What is the RFC 7807 standard?

Problem Details for HTTP APIs — a standardized format for error responses with type, title, status, detail, and instance fields.

2. Why use error codes instead of messages for client logic?

Error codes are stable identifiers. Message strings change with localization and are fragile for programmatic handling.

3. What is field mapping?

Translating internal field names (user.profile.email) to external names (email_address) in error responses to hide schema details.

4. How do you localize validation errors?

Use a message catalog per locale with template strings. Accept a locale parameter and translate error codes at response time.

Challenge

Build a complete error response system supporting RFC 7807 with localization (en/es/fr), field mapping, nested error flattening, and multiple output formats (REST, Graphql, JSON:API).

FAQ

What is the best format for validation errors?

RFC 7807 Problem Details for REST APIs. It is standard, extensible, and well-supported by HTTP libraries.

Should I expose internal field names in errors?

No. Map to user-facing names. Internal 'user.account.email' becomes 'email_address' in the response.

How do I handle localization for error messages?

Use a template-based system with locale dictionaries. Accept an Accept-Language header or locale query parameter.

What error codes should I use?

Use short kebab-case codes like 'required', 'min-length', 'email-format'. Document them in your API reference.

How many errors should I return at once?

Return all validation errors in a single response. This lets clients fix everything before retrying.

Mini Project: Localized Error Response Handler

# error_handler.py
from typing import Any, Dict, List, Optional

class ErrorHandler:
    def __init__(self):
        self.locales = {
            "en": {"required": "{field} is required", "email": "Invalid email"},
            "es": {"required": "{field} es obligatorio", "email": "Email invalido"},
        }
        self.field_map = {"user_email": "email", "user_pass": "password"}

    def translate(self, code: str, field: str, locale: str = "en") -> str:
        msgs = self.locales.get(locale, self.locales["en"])
        template = msgs.get(code, f"Invalid {field}")
        return template.format(field=field)

    def format_error(self, field: str, code: str, locale: str = "en") -> Dict:
        external = self.field_map.get(field, field)
        return {
            "field": external,
            "code": code,
            "message": self.translate(code, external, locale),
        }

    def handle(self, raw_errors: List[Dict], locale: str = "en") -> Dict:
        formatted = [self.format_error(e["field"], e["code"], locale) for e in raw_errors]
        return {"valid": False, "count": len(formatted), "errors": formatted}

handler = ErrorHandler()
raw = [{"field": "user_email", "code": "required"}, {"field": "user_pass", "code": "required"}]

print(handler.handle(raw, "en"))
print(handler.handle(raw, "es"))

Expected output:

{'valid': False, 'count': 2, 'errors': [{'field': 'email', 'code': 'required', 'message': 'email is required'}, {'field': 'password', 'code': 'required', 'message': 'password is required'}]}
{'valid': False, 'count': 2, 'errors': [{'field': 'email', 'code': 'required', 'message': 'email es obligatorio'}, {'field': 'password', 'code': 'required', 'message': 'password es obligatorio'}]}

What's Next

You understand error messages. Next, learn validation security, then honeypot validation.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro