Python is often called a dynamically typed language, and that is correct. But there is another half to the story that gets overlooked: Python is also strongly typed. Understanding what that means—and what it does not mean—will change how you think about errors, data safety, and the code you write every day.
If you have ever tried to concatenate a string and a number in Python and received a TypeError, you have already experienced strong typing in action. Python refused to silently convert one value into another on your behalf. That refusal is the very thing that makes Python strongly typed, and it is one of the language's most important safety features.
Strong vs. Weak Typing Explained
The terms "strong" and "weak" typing describe how strictly a language enforces the boundaries between different data types during operations. In a strongly typed language, you cannot freely mix incompatible types without an explicit conversion. In a weakly typed language, the interpreter or compiler will attempt to coerce values behind the scenes to make an operation work, even when the types do not match.
Consider the expression "hello" + 5. In JavaScript, a weakly typed language, this produces the string "hello5". JavaScript silently converts the number 5 into the string "5" and then concatenates the two values. No error, no warning.
In Python, the same expression raises a TypeError:
>>> "hello" + 5
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: can only concatenate str (not "int") to str
Python does not guess what you meant. It does not assume that the number should become a string, or that the string should become a number. It tells you the types are incompatible and stops execution. That behavior is the core of strong typing.
Strong and weak typing exist on a spectrum, not as a binary switch. Python sits firmly on the strong end of that spectrum. It performs very few implicit conversions compared to languages like JavaScript, Perl, or PHP, which perform many.
How Python Enforces Strong Typing
Every value in Python is an object, and every object has a type. When you use an operator like +, Python does not simply "add" two things. It delegates the operation to the objects involved by calling special methods under the hood.
For example, when Python encounters x + y, it first calls the __add__ method on x, passing y as an argument. If x does not know how to handle the type of y, it returns a special sentinel value called NotImplemented. Python then tries the reverse by calling __radd__ on y. If neither object can handle the operation, Python raises a TypeError.
# Strings do not know how to add themselves to integers
>>> "Trey" + 2
TypeError: can only concatenate str (not "int") to str
# Lists do not know how to add themselves to sets
>>> [2, 1, 0] + {2, 23}
TypeError: can only concatenate list (not "set") to list
# Dictionaries do not support subtraction at all
>>> {"a": 1} - {"a": 1}
TypeError: unsupported operand type(s) for -: 'dict' and 'dict'
This is fundamentally different from type coercion. Python never silently transforms one object into a different type to make an operation succeed. If two types are incompatible, the operation fails loudly. This means that bugs caused by unexpected type conversions—the kind that can silently corrupt data and surface hours later in a completely different part of your codebase—are far less likely in Python.
What About Boolean Operations?
Python treats certain values as "truthy" or "falsy" in boolean contexts. An empty string, the number 0, an empty list, and None all evaluate to False. Non-empty containers and non-zero numbers evaluate to True. This is not the same as type coercion. Python is not converting your list into a boolean. It is calling the object's __bool__ (or __len__) method to determine truthiness. The original object's type remains unchanged.
my_list = []
if not my_list:
print("The list is empty")
# my_list is still a list, not a boolean
print(type(my_list)) # <class 'list'>
The Narrow Exceptions: Where Python Does Convert
Python is strongly typed, but it is not absolutist about it. There are a few narrow cases where Python does perform automatic conversion between types. The important detail is that these conversions happen within a single numeric hierarchy, and they always move from a less precise type to a more precise type to prevent data loss.
# Integer + Float = Float
>>> 3 + 4.5
7.5
# Integer + Complex = Complex
>>> 2 + 3j
(2+3j)
# Boolean + Integer = Integer (True is treated as 1)
>>> True + 7
8
In the first example, when you add an int and a float, Python promotes the integer to a float before performing the addition. This is not arbitrary type coercion. The float type's __radd__ method explicitly knows how to accept integers, and it handles the operation internally. The integer itself is not converted. Rather, the float object knows how to work with integer operands.
Python's numeric hierarchy follows the pattern: bool → int → float → complex. Automatic promotion only happens moving from left to right in this chain. You will never see Python silently convert a float to an integer, because that would lose the decimal portion.
Crucially, these promotions are restricted to Python's built-in numeric types. Python will never automatically convert a string to a number, a list to a tuple, or a dictionary to a list. If you want those conversions, you must be explicit about it by calling int(), str(), list(), or whatever conversion function is appropriate.
# You must explicitly convert when types are incompatible
age = 25
message = "I am " + str(age) + " years old"
print(message) # I am 25 years old
# Or use f-strings, which handle conversion for you
message = f"I am {age} years old"
print(message) # I am 25 years old
Strong Typing vs. Static Typing
One of the common sources of confusion when discussing Python's type system is conflating strong typing with static typing. These are two separate concepts that describe different things.
Strong vs. Weak describes whether a language performs implicit type conversions. Python is strongly typed because it rarely converts types behind the scenes.
Static vs. Dynamic describes when types are checked. In a statically typed language like Java or C++, types are checked at compile time, and variables must be declared with a specific type that cannot change. In a dynamically typed language like Python, types are checked at runtime, and a variable can hold a value of any type at any point during execution.
# Dynamic typing: the same variable can hold different types
x = 42 # x is an int
x = "hello" # now x is a str
x = [1, 2, 3] # now x is a list
# Strong typing: but you still cannot mix them improperly
result = x + 10 # TypeError: can only concatenate list (not "int") to list
Python is both strongly typed and dynamically typed. These two properties are independent of each other. A language can be any combination of the two:
- Strongly typed + Statically typed: Java, Rust, Haskell
- Strongly typed + Dynamically typed: Python, Ruby, Erlang
- Weakly typed + Statically typed: C (allows pointer casting and memory reinterpretation)
- Weakly typed + Dynamically typed: JavaScript, Perl, PHP
Because Python does not require you to declare variable types, some people assume it is weakly typed. That assumption is incorrect. The absence of type declarations is about dynamic typing, not weak typing. Python absolutely enforces type boundaries—it just does so at runtime rather than at compile time.
Type Hints and the Modern Python Ecosystem
Since PEP 484 was introduced in Python 3.5, developers have been able to add optional type annotations to their code. These annotations do not change Python's runtime behavior. The interpreter ignores them entirely. But they allow external tools like mypy, pyright, and Pyrefly to analyze code before it runs and catch type-related mistakes early.
def greet(name: str, times: int) -> str:
return name * times
# A type checker like mypy would flag this as an error:
# greet(42, "hello")
# error: Argument 1 has incompatible type "int"; expected "str"
Type hints bring some of the benefits of static typing into Python's dynamically typed world. They make function signatures self-documenting, help IDEs provide better autocomplete and error detection, and allow teams working on large codebases to catch bugs before code reaches production.
The Python typing ecosystem has matured significantly over the past several years. Python 3.9 introduced native type hints for collections like list[str] instead of requiring List[str] from the typing module. Python 3.10 added the union syntax X | Y as an alternative to Union[X, Y]. Python 3.12 brought the type statement for creating type aliases more cleanly. Each release has made type annotations simpler and more readable.
For runtime type enforcement, libraries like Pydantic and Beartype validate data against type annotations at execution time. Pydantic, in particular, has become a standard tool in API development, where incoming data needs to be validated and converted to the expected types before it is processed.
from pydantic import BaseModel
class User(BaseModel):
name: str
age: int
# Pydantic validates types at runtime
user = User(name="Alice", age=30) # Works
user = User(name="Bob", age="twenty") # Raises ValidationError
You do not need to annotate every line of code all at once. Python's type system supports gradual adoption. Start by adding type hints to function signatures and public interfaces, then expand coverage over time. Tools like mypy can be configured to check only annotated code.
Key Takeaways
- Python is strongly typed: It does not silently convert values between incompatible types. When you try to mix strings with numbers or lists with sets, Python raises a
TypeErrorinstead of guessing your intent. - Strong typing and dynamic typing are independent concepts: Python is dynamically typed (types are checked at runtime, and variables can change types) but also strongly typed (operations between incompatible types are not allowed without explicit conversion).
- Numeric promotion is the exception, not the rule: Python will automatically widen
inttofloatorfloattocomplexduring arithmetic. This is a deliberate, narrow behavior that prevents data loss within the numeric hierarchy. - Type hints add a voluntary layer of safety: While Python's interpreter does not enforce annotations, tools like mypy and pyright can catch type errors before your code runs. Libraries like Pydantic bring runtime validation to the table.
- Strong typing catches bugs early: By refusing to perform hidden conversions, Python ensures that type mismatches surface immediately at the point where they occur—not somewhere far downstream in your code where they would be much harder to diagnose.
Python's strong typing is one of its quiet strengths. It gives you the flexibility of dynamic typing—no need to declare variable types, no rigid compile-time constraints—while still protecting you from the class of silent, data-corrupting bugs that plague weakly typed languages. When Python throws a TypeError, it is not getting in your way. It is telling you exactly where your assumptions about your data went wrong, and that is the kind of error message you want to receive.