2. User-centric error messages
Stack traces tell developers where code broke. Users need something different: what happened in plain language, what they should do about it, and an example that makes the solution obvious. This three-part pattern is the smallest change you can make that turns technical failures into actionable guidance, and everything else in the chapter depends on having somewhere to put the text it produces.
Traceback (most recent call last):
File "weather_dashboard.py", line 34, in find_location
location = data["results"][0]
KeyError: 'results'
We couldn't find weather data for "Lndon".
Please check the spelling or try a nearby city.
Examples: London, Dublin, Tokyo
Same situation - city not found - but the second message tells users exactly what happened and what to do about it. No cryptic error codes, no stack traces, no confusion. This is the foundational principle: everything else we build serves this goal of helping users succeed when things go wrong.
The three-part pattern in detail
What happened
Describe the problem in plain language, without technical jargon. Connect it to the user's action so they understand the context.
Bad: "KeyError: 'results'"
Good: "We couldn't find weather data for 'Lndon'"
What to do
Give specific, actionable guidance they can act on immediately. Avoid vague advice like "try again later" or "check your input".
Bad: "Please enter valid input"
Good: "Please check the spelling or try a nearby city"
Concrete examples
Show specific examples of what would work. Remove all ambiguity about what "valid input" means.
Bad: "Enter a city name"
Good: "Examples: London, Dublin, Tokyo"
This pattern mirrors how humans naturally help each other. When someone asks for directions and gets lost, you don't say "NavigationError at coordinates 51.5074, -0.1278". You say "You're on the wrong street. Turn around and take the second left. Look for the red building next to the park." That's the three-part pattern: what/how/examples.
Applying the pattern across error types
Let's see how the three-part pattern transforms different error scenarios:
Traceback (most recent call last):
File "weather_dashboard.py", line 34, in find_location
location = data["results"][0]
KeyError: 'results'
We couldn't find weather data for "Lndon".
Please check the spelling or try a nearby city.
Examples: London, Dublin, Manchester
- Part 1 (What): "We couldn't find weather data for 'Lndon'" - Users see their exact input quoted back and understand it wasn't recognized
- Part 2 (How): "Check the spelling or try a nearby city" - Clear action steps to resolve the problem
- Part 3 (Examples): "London, Dublin, Manchester" - Shows similar valid cities, making the fix obvious
requests.exceptions.ReadTimeout: HTTPSConnectionPool(host='api.open-meteo.com',
port=443): Read timed out. (read timeout=5)
We're having trouble connecting to the weather service.
This is usually temporary - please try again in a moment.
If the problem continues, check your internet connection.
ValueError: City name cannot be empty
Please enter a city name to get weather information.
Type the name of any city or town.
Examples: London, Paris, Tokyo
Notice the pattern consistency: every message explains what happened, tells users what to do, and provides concrete examples. This predictability builds trust - users learn they can rely on your application to guide them toward success.
Why error messages earn their keep
The same rewriting exercise shows up in every kind of application. A checkout page that fails with Transaction declined – Code 51 sends users to their browser's back button. The user has no idea what went wrong, no idea what to try next, and no tolerance for support tickets. The three-part rewrite changes the outcome:
Transaction declined - Code 51
Your card was declined.
Please check the card number and expiration date are correct.
If the information is correct, contact your bank to authorize the transaction.
The technical accuracy is identical — both messages say the card was declined. Only the presentation changed. But that presentation difference is what decides whether the user corrects a typo and retries, or closes the tab and shops somewhere else. Error messages aren't decoration. They're part of the same user flow as the success screens, and they either carry the user forward or drop them off the path.
Design principles for error messages
Five habits that keep the three-part pattern honest:
- Never show stack traces to users. Stack traces are for developers at debugging time. Lines like
File 'weather_dashboard.py', line 34mean nothing to someone trying to check the weather; they create confusion and anxiety. Show friendly guidance, save the technical detail for the logs. - Always quote user input back. "We couldn't find 'Lndon'" beats "We couldn't find that city". The confirmation shows exactly what was processed and makes the typo obvious. Users spot their own mistakes without guessing what went wrong.
- Make examples relevant to context. If someone searches for "Lndon", suggest "London, Dublin, Manchester" — nearby places. If they search for "Tkyo", suggest "Tokyo, Osaka, Kyoto". Context-aware examples feel more helpful than generic ones. When you can't determine context, fall back to geographically diverse suggestions.
- Keep language friendly, not patronising. Write like you're helping a colleague, not talking to a child. "We couldn't find that city" works better than "Oopsie! Looks like we had a little problem!" Respect users' intelligence while giving clear guidance.
- Be specific about actions. "Try again later" is vague. When should they try? Minutes? Hours? "The weather service is currently unavailable. Please try again in 5 minutes" sets a clear expectation. Specific guidance removes ambiguity and tells the user when it's worth coming back.
Implementation: message templates
Centralizing all error messages in a single data structure makes them consistent, maintainable, and testable. The keys use the four categories from the overview: user_input, transient, not_found, and unknown. You'll build the categorizer that chooses between them on the next page; for now, create a MESSAGE_TEMPLATES dictionary that maps each category to three-part messages:
MESSAGE_TEMPLATES = {
"user_input": {
"empty": (
"Please enter a city name to get weather information.",
"Type the name of any city or town.",
"Examples: London, Paris, Tokyo"
),
"too_long": (
"That city name is too long (maximum 100 characters).",
"Please enter a shorter name.",
"Examples: London, San Francisco, Mexico City"
),
"invalid_chars": (
"City names can only contain letters, spaces, hyphens, and apostrophes.",
"Please check your input and try again.",
"Examples: London, Saint-Denis, O'Fallon"
)
},
"transient": {
"timeout": (
"We're having trouble connecting to the weather service.",
"This is usually temporary - please try again in a moment.",
"If the problem continues, check your internet connection."
),
"connection": (
"We're having trouble connecting to the weather service.",
"This is usually temporary - please try again in a moment.",
"If the problem continues, check your internet connection."
),
"rate_limit": (
"We've made too many requests to the weather service.",
"Please wait {retry_seconds} seconds before trying again.",
"This helps us stay within service limits."
),
"server_error": (
"The weather service is currently experiencing issues.",
"This is temporary - please try again in a few minutes.",
"If the problem continues, check the service status page."
)
},
"not_found": {
"city": (
"We couldn't find weather data for \"{city_name}\".",
"Please check the spelling or try a nearby city.",
"Examples: {suggestions}"
)
},
"unknown": {
"general": (
"Something unexpected happened while getting weather data.",
"Please try again. If the problem continues, contact support.",
"Error details have been logged for investigation."
)
}
}
def compose_error_message(category, error_type, **context):
"""Compose a three-part error message from templates."""
template = MESSAGE_TEMPLATES[category][error_type]
# Add default suggestions if missing (not_found templates require them)
if category == "not_found" and "suggestions" not in context:
context["suggestions"] = "London, Paris, Tokyo"
if category == "not_found" and "city_name" not in context:
context["city_name"] = "that city"
# Format each part with context variables
what_happened = template[0].format(**context)
what_to_do = template[1].format(**context)
examples = template[2].format(**context)
return f"{what_happened}\n{what_to_do}\n{examples}"
This centralized approach has several advantages:
- Single source of truth: All error text lives in one place, making updates and translations easy
- Enforced structure: Every message automatically follows the three-part pattern
- Testability: Verify templates contain required parts and use correct variables
- Consistency: All errors use the same friendly tone and format
When product managers want to change message tone or add translation support, you update templates once rather than hunting through scattered string literals across your codebase.