5.6 Functions and Type Hints
Table of Contents
Why Functions Matter in Backend Python
In backend development you will write functions all the time. Functions let you:
- Group related logic in one place
- Reuse code instead of copying and pasting
- Give names to operations so your code reads like documentation
- Test small pieces of behavior independently
As a backend developer you also want functions to be easy to understand and safe to use. That is where type hints help. Together, functions and type hints are one of your main tools for writing clear and reliable backend code.
In this chapter you will focus on:
- How to define and use functions in Python
- Different kinds of function parameters
- Return values
- How to add type hints to parameters and return values
- How to type common backend data structures
- How type checking fits into real backend projects
Defining and Calling Functions
A function in Python is created with the def keyword.
def greet():
print("Hello from the backend!")You call (or invoke) the function by using its name followed by parentheses:
greet() # prints: Hello from the backend!This is a function with:
- No parameters
- No explicit return value
Parameters and Arguments
Most backend functions need input. You declare inputs as parameters. When you call the function, you pass arguments.
def greet_user(name):
print(f"Hello, {name}!")
greet_user("Alice") # "Alice" is the argument
greet_user("Bob")Here:
nameis the parameter"Alice"and"Bob"are arguments
You can define multiple parameters:
def create_user(username, email):
print(f"Creating user {username} with email {email}")
create_user("alice", "alice@example.com")Return Values
Functions can send a value back to the caller using return.
def add(a, b):
result = a + b
return result
sum_value = add(3, 5) # sum_value is 8
If you do not use return, or you use return without a value, Python returns None:
def log_message(message):
print(f"[LOG] {message}")
value = log_message("Server started")
print(value) # prints: NoneYou can return any type:
def is_adult(age):
return age >= 18 # bool
def get_default_port():
return 8000 # int
def get_default_headers():
return {"Content-Type": "application/json"} # dictDefault Parameter Values
You can give parameters default values. These are used when the caller does not pass a value.
def send_email(to_address, subject, body="", is_html=False):
print(f"Sending email to {to_address}")
print(f"Subject: {subject}")
print(f"HTML? {is_html}")
print(f"Body: {body}")Examples of calling:
send_email("user@example.com", "Welcome")
send_email(
"user@example.com",
"Weekly report",
body="Here is your report...",
)
send_email(
"user@example.com",
"Password reset",
body="<p>Click the link</p>",
is_html=True,
)Order rule: parameters must follow this order:
- Required parameters (no default)
- Parameters with default values
Rule: In Python function definitions, all parameters without default values must come before any parameters with default values.
For example, def f(a, b=1) is valid, but def f(a=1, b) is an error.
Keyword Arguments
You can call functions using keyword arguments, where you specify parameter_name=value:
def connect_db(host, port, user, password):
...
connect_db(
host="localhost",
port=5432,
user="app_user",
password="secret",
)You can change the order when you use keywords:
connect_db(
password="secret",
user="app_user",
port=5432,
host="localhost",
)In backend code keyword arguments often make calls clearer, especially when many arguments are the same type, like several strings or numbers.
Variable-Length Arguments: *args and **kwargs
Sometimes you do not know how many arguments a function may receive. Python has two special patterns:
*args: Extra Positional Arguments
*args collects extra positional arguments into a tuple.
def log_all(*messages):
for message in messages:
print("[LOG]", message)
log_all("Server started")
log_all("DB connected", "Cache ready", "Worker started")Here:
messagesis a tuple of all positional arguments.
**kwargs: Extra Keyword Arguments
**kwargs collects extra keyword arguments into a dictionary.
def log_event(event_name, **data):
print(f"Event: {event_name}")
for key, value in data.items():
print(f" {key} = {value}")
log_event(
"user_login",
user_id=123,
ip="192.168.0.1",
country="US",
)Here:
datais adictwith all keyword arguments.
These patterns are very common in backend libraries, for example in logging, configuration, or database query helpers.
Return Early and Guard Clauses
Backend functions often need to validate input and stop early if something is wrong.
def normalize_page(page):
if page < 1:
return 1
return pageThis is called a guard clause. It keeps functions short and clear.
Another example:
def get_first_or_none(items):
if not items:
return None
return items[0]Why Type Hints Matter for Backend Code
Python is dynamically typed, so the interpreter does not require you to specify types. You can write:
def add(a, b):
return a + bThis works, but:
- You cannot see at a glance what types
aandbshould be - Your editor cannot catch type mistakes before you run the code
- Big backend projects become harder to understand
Type hints solve this. They are optional hints that describe the expected types.
Type hints are checked by tools such as mypy or pyright, not by Python at runtime.
Basic Type Hints for Function Parameters
You add type hints after a colon :.
def add(a: int, b: int):
return a + bThis means:
ashould be anintbshould be anint
Python still allows other types at runtime, but type checkers and IDEs will warn you.
Some basic built-in types you will use:
| Type | Example value | Where used in backend |
|---|---|---|
int | 200, 404, 8080 | Status codes, ports, IDs, counts |
float | 3.14, 0.5 | Prices, percentages, time intervals |
str | "GET", "application/json" | HTTP methods, headers, messages |
bool | True, False | Flags like is_active, is_admin |
bytes | b"raw data" | Binary files, network data |
Example with parameters:
def send_response(status_code: int, body: str) -> None:
print(f"Status: {status_code}")
print("Body:", body)Type Hints for Return Values
You annotate the return type with -> after the parameter list.
def add(a: int, b: int) -> int:
return a + b
If a function returns nothing useful, use -> None:
def log_error(message: str) -> None:
print(f"[ERROR] {message}")If a function returns different types in different branches, you should try to make it consistent. If that is not possible, you will use unions, which you will see later.
Rule: Every non trivial function in backend code should have an explicit return type hint.
For example, use -> int, -> str, -> dict, or -> None, instead of leaving it unannotated.
Using Type Hints with Default Values
Default values work together with type hints:
def paginate(page: int = 1, page_size: int = 20) -> None:
print(f"Page {page}, size {page_size}")You still specify the type, even if a default value is present. The default must match the type.
Typing Common Data Structures
Backend code uses collections all the time. You will use the typing module.
In Python 3.9 or newer, you can write:
from typing import Dict, List, Tuple, Set
or use built-in generics like list[int]. Both styles are common. Here you will show both, then focus on the newer style.
Lists
A list of integers:
from typing import List
def sum_scores(scores: List[int]) -> int:
return sum(scores)Newer syntax:
def sum_scores(scores: list[int]) -> int:
return sum(scores)Dictionaries
A dictionary from str to int, for example a mapping of status code names to numbers:
from typing import Dict
def get_status_codes() -> Dict[str, int]:
return {"ok": 200, "not_found": 404}Newer syntax:
def get_status_codes() -> dict[str, int]:
return {"ok": 200, "not_found": 404}Tuples
A tuple with a fixed shape such as (host, port):
from typing import Tuple
def get_default_db_address() -> Tuple[str, int]:
return ("localhost", 5432)Newer syntax:
def get_default_db_address() -> tuple[str, int]:
return ("localhost", 5432)Optional and None
Many backend functions can return "no result" instead of a value. Type hints should represent that.
You use Optional[T] or T | None.
from typing import Optional
def find_user_email(user_id: int) -> Optional[str]:
# None means not found
if user_id == 1:
return "admin@example.com"
return NoneIn newer syntax:
def find_user_email(user_id: int) -> str | None:
if user_id == 1:
return "admin@example.com"
return None
To annotate a parameter that can be None:
def send_welcome_email(email: str | None) -> None:
if email is None:
print("No email to send welcome message to.")
return
print(f"Sending welcome email to {email}")Unions: One of Several Types
Sometimes a function can accept or return one of several types.
You can write Union[A, B] or A | B.
from typing import Union
def parse_user_id(raw: Union[int, str]) -> int:
if isinstance(raw, int):
return raw
return int(raw)Newer syntax:
def parse_user_id(raw: int | str) -> int:
if isinstance(raw, int):
return raw
return int(raw)Typing *args and **kwargs
You can give types to variable-length arguments too.
Typed *args
from typing import Any
def log_many(*messages: str) -> None:
for msg in messages:
print("[LOG]", msg)
This says: every positional argument must be a str.
Typed **kwargs
def build_filters(**filters: str) -> dict[str, str]:
# filters might be: status="active", role="admin"
return filtersThis says:
- Every keyword value must be a
str - The function returns a
dict[str, str]
Callable Types
Sometimes you pass one function into another, for example a callback.
You can use Callable to type this.
from typing import Callable
def run_query_with_logger(query: str, logger: Callable[[str], None]) -> None:
logger(f"Running query: {query}")
# execute query here
logger("Query finished")Callable[[str], None]means a function that takes astrand returnsNone.
You can use it like this:
def simple_logger(message: str) -> None:
print("[DB]", message)
run_query_with_logger("SELECT 1", simple_logger)Type Aliases for Better Readability
If a type is long or used many times, you can give it a name.
from typing import Dict, Any
JSON = Dict[str, Any]
def parse_request_body(body: bytes) -> JSON:
# pretend to parse JSON
return {"raw": body.decode("utf-8")}Newer syntax:
from typing import Any
JSON = dict[str, Any]
def parse_request_body(body: bytes) -> JSON:
return {"raw": body.decode("utf-8")}This makes function signatures easier to read, especially in backend code where you often deal with JSON-like structures.
Practical Backend Examples with Type Hints
Here are some realistic small backend-style functions with type hints.
Parsing Query Parameters
def parse_int_param(raw: str | None, default: int = 0) -> int:
if raw is None:
return default
try:
return int(raw)
except ValueError:
return defaultUsage:
page_str = "5" # pretend from query string
page = parse_int_param(page_str, default=1) # page is intBuilding an HTTP Response Object
from typing import Any
Headers = dict[str, str]
Body = dict[str, Any]
def make_json_response(
data: Body,
status_code: int = 200,
headers: Headers | None = None,
) -> dict[str, Any]:
if headers is None:
headers = {}
headers.setdefault("Content-Type", "application/json")
return {
"status_code": status_code,
"headers": headers,
"body": data,
}Finding a User
from typing import TypedDict, Optional
class User(TypedDict):
id: int
username: str
email: str
def get_user_by_id(user_id: int) -> Optional[User]:
if user_id == 1:
return {
"id": 1,
"username": "admin",
"email": "admin@example.com",
}
return None
Here User is a typed dictionary. You will learn more about structured models later, but this gives you a feel for how type hints describe real backend data.
Type Checking and Tooling
Type hints alone do nothing at runtime. To benefit from them you use tools:
- mypy: static type checker for Python
- pyright: type checker and language server
- IDEs like VS Code and PyCharm can use type hints to:
- Show better autocompletion
- Highlight type errors while you type
- Help navigate code in big backend projects
A typical workflow:
- Add type hints to your functions
- Run
mypyin your project - Fix reported issues
- Repeat
You do not need to be perfect. Even partial type coverage is very useful in real backend projects.
Common Pitfalls with Functions and Type Hints
Mutable Default Arguments
Never use a mutable object as a default value.
Bad:
def add_header(headers: dict[str, str] = {}): # bad
headers["X-App"] = "my-app"
return headersThis dictionary is shared between calls.
Correct:
from typing import Optional
def add_header(headers: Optional[dict[str, str]] = None) -> dict[str, str]:
if headers is None:
headers = {}
headers["X-App"] = "my-app"
return headers
Type hints help you see that headers can be None.
Inconsistent Return Types
Avoid functions that sometimes return one type and sometimes another unrelated type.
Bad:
def get_user_maybe(user_id: int):
if user_id == 1:
return {"id": 1}
return FalseBetter:
from typing import Optional
def get_user_maybe(user_id: int) -> Optional[dict[str, int]]:
if user_id == 1:
return {"id": 1}
return NoneSummary
In this chapter you learned:
- How to define functions with parameters, default values, and return values
- How to use
argsand*kwargsfor flexible APIs - How to add type hints to function parameters and return values
- How to type collections like
list,dict, andtuple - How to use
Optional, unions,Callable, and type aliases - How type hints make backend Python code clearer, safer, and easier to maintain
You now have the tools to write clear, typed functions in Python, which is the foundation for building reliable backend services.
Views: 8
KAHIBARO