A few years ago, I watched a perfectly reasonable “real-number-only” solution drift into a swamp of special cases: rotating vectors, combining sinusoids, and tracking phase offsets across a signal-processing pipeline. Every step was correct, but the code felt brittle—too many paired variables (realpart, imagpart), too many manual trig calls, too many chances to swap a sign.
Python’s complex numbers are the antidote to that kind of accidental complexity. You get a single value that naturally represents “two numbers that travel together,” plus a standard library module (cmath) built for the arithmetic you actually need: magnitude, phase, polar/rectangular conversion, exponentials, trig, and more.
Here’s what I’ll walk you through: how complex numbers are represented in Python, the two clean ways to create them, how to read their real/imaginary parts, what “phase” means (with a mental picture you can keep), and how to convert between rectangular form (x + yj) and polar form (r, phi). Along the way, I’ll call out the mistakes I see most often in code reviews and show you patterns that hold up in real systems.
Complex Numbers as First-Class Values in Python
In Python, a complex number is a built-in numeric type. That means you can add, subtract, multiply, divide, and store it in data structures the same way you do with int or float. The canonical math notation is:
- Rectangular form:
x + yi(math textbooks) - Python form:
x + yj(Python usesjfor the imaginary unit)
Why j? Engineering tradition (especially electrical engineering) often reserves i for current, so j became common. Python follows that convention.
A key idea I want you to keep: complex numbers are not “fancy floats.” They’re a pair of real values (usually stored as floats) that behave as a single unit.
Here’s a simple, runnable example that demonstrates arithmetic and type behavior:
z1 = 2 + 3j
z2 = 4 – 1j
print(type(z1))
print(z1 + z2) # addition
print(z1 * z2) # multiplication
print(z1 / z2) # division
A few practical notes I rely on:
- If either operand is complex, Python promotes the operation to complex.
- Many operations you already know carry over directly.
- The complex type is immutable, which makes it safe to share across functions and threads.
When you start doing geometry, signals, rotations, or frequency-domain work, complex numbers reduce “two related floats” into “one correct value,” and that alone makes your code easier to reason about.
Creating Complex Numbers: complex(x, y) vs x + yj
There are two idiomatic ways to create complex values:
1) The constructor: complex(x, y)
2) A literal: x + yj
I use complex(x, y) when I’m converting existing real-valued variables into a complex value, especially if the parts are coming from parsing, APIs, or separate calculations.
import cmath
x = 5
y = 3
z = complex(x, y)
print(‘z =‘, z)
print(‘real =‘, z.real)
print(‘imag =‘, z.imag)
And I use the literal form when I’m writing constants or quick expressions in code.
z = 5 + 3j
print(‘real =‘, z.real)
print(‘imag =‘, z.imag)
Two details that matter in real projects:
complex(x, y)will convertxandyto floats internally (when they’re ints), soz.realandz.imagcommonly show up as5.0and3.0.- If you have a string like
‘5+3j‘, you can docomplex(‘5+3j‘), but that’s parsing, and it has sharp edges. I’ll cover safer patterns later.
A Quick “Traditional vs Modern” View
When you’re building complex values from data, the difference between a clear constructor and a fragile ad-hoc parse shows up fast.
Traditional approach
—
Keep (x, y) tuple everywhere
complex(x, y) early x1+x2, y1+y2
z1 + z2 Manual cos/sin with two equations
cmath.rect(1, angle) If you take one thing from this section: convert into complex at the boundaries of your system (input parsing, I/O, API adapters) so the core of your code can stay simple.
Reading Real and Imaginary Parts (and Printing Them Like a Human)
Python exposes the parts of a complex number as attributes:
z.realz.imag
They are attributes, not functions. I still see people try z.real() in older codebases; that will raise a TypeError.
z = -2.5 + 7j
print(‘Real:‘, z.real)
print(‘Imag:‘, z.imag)
Formatting tips I actually use
If you log complex numbers, the default formatting is usually fine ((a+bj)), but sometimes you want more control:
z = 1.234567 + 8.9j
print(f‘z = {z}‘)
print(f‘rect = {z.real:.3f} + {z.imag:.3f}j‘)
If you’re producing user-facing output, I recommend you decide on one canonical format early (rectangular or polar) and stick to it. Mixing formats across logs and dashboards makes debugging harder than it needs to be.
Equality and “close enough”
Complex parts are floats most of the time, and floats come with rounding behavior. Don’t do exact equality checks unless you truly control all inputs.
import math
z = complex(0.1 + 0.2, 0.3)
expected = complex(0.3, 0.3)
print(z == expected) # often False due to float rounding
print(math.isclose(z.real, expected.real, reltol=1e-12, abstol=0.0))
print(math.isclose(z.imag, expected.imag, reltol=1e-12, abstol=0.0))
In numeric code, I typically compare both parts with math.isclose, or compare magnitudes of the difference: abs(z - expected) < tolerance.
Magnitude and Phase: A Picture You Can Keep in Your Head
A complex number z = x + yj can be viewed as a point (or vector) in a 2D plane:
- The horizontal axis is the real part (
x). - The vertical axis is the imaginary part (
y).
From that picture, two geometric values fall out naturally:
- Magnitude (also called modulus): distance from the origin to the point
- Phase (also called argument): the angle from the positive real axis to the vector
Magnitude in Python
Use abs(z) to get the magnitude.
z = 3 + 4j
print(abs(z)) # 5.0
This matches the 3-4-5 triangle: sqrt(3^2 + 4^2) = 5.
Phase in Python
Use cmath.phase(z) to get the phase angle in radians.
import cmath
z = -1 + 0j
print(cmath.phase(z)) # close to pi
A detail I want you to remember: phase is typically returned in the range [-pi, +pi]. That makes it consistent, but it also means angles “wrap around” at the ends of that interval.
If you ever see a jump from about +3.13 to -3.14, that’s not a bug in your math; it’s the representation crossing the wrap boundary.
Radians vs degrees
cmath.phase uses radians because most of Python’s math APIs use radians. If you want degrees for display:
import cmath
import math
z = 1 + 1j
phi = cmath.phase(z)
print(phi)
print(math.degrees(phi))
In my experience, the least painful approach is:
- Keep radians inside the system.
- Convert to degrees only at the edges (display, reports, UI).
Rectangular and Polar Forms: Converting Both Directions
Once you accept the “point in a plane” model, it’s natural to switch between:
- Rectangular:
x + yj - Polar:
(r, phi)meaning “radius and angle”
Python’s cmath module gives you both conversions.
Rectangular to polar: cmath.polar(z)
cmath.polar(z) returns a pair (r, phi):
ris the magnitude (same asabs(z))phiis the phase angle (same ascmath.phase(z))
import cmath
z = 1 + 1j
r, phi = cmath.polar(z)
print(‘z:‘, z)
print(‘r:‘, r)
print(‘phi (rad):‘, phi)
Polar to rectangular: cmath.rect(r, phi)
cmath.rect(r, phi) converts back to a complex value that is numerically equivalent to:
r (cos(phi) + sin(phi) 1j)
import cmath
r = 2.0
phi = 0.7853981633974483 # about pi/4
z = cmath.rect(r, phi)
print(z)
A full round-trip example (with a reality check)
When you convert from rectangular to polar and back, you might see tiny floating-point artifacts. That’s normal.
import cmath
z1 = 1 + 1j
r, phi = cmath.polar(z1)
z2 = cmath.rect(r, phi)
print(‘z1:‘, z1)
print(‘z2:‘, z2)
print(‘difference magnitude:‘, abs(z1 – z2))
Expect z2 to be extremely close to z1, but not always identical in its printed decimal expansion.
A practical pattern: rotate a 2D vector
Rotations are where complex numbers really start paying rent.
If you treat a 2D point (x, y) as a complex number z = x + yj, rotating it by angle theta is multiplication:
z_rotated = z * rect(1, theta)
import cmath
import math
# Point at (10, 0)
z = 10 + 0j
# Rotate 30 degrees counterclockwise
theta = math.radians(30)
rotator = cmath.rect(1.0, theta)
z_rot = z * rotator
print(‘rotated:‘, z_rot)
print(‘x, y:‘, zrot.real, zrot.imag)
This is a clean, hard-to-mess-up alternative to manually writing the rotation matrix each time.
What Makes Complex Numbers So Useful: Euler’s Formula (Without the Handwaving)
If complex numbers ever felt “abstract” to you, this is the bridge to practical intuition.
Euler’s formula tells us:
e^(jtheta) = cos(theta) + jsin(theta)
That means a pure rotation in the plane can be represented as multiplication by e^(j*theta).
In Python, the cmath module makes this concrete:
import cmath
import math
theta = math.radians(90)
rotator = cmath.exp(1j * theta)
print(rotator) # approximately 0+1j
Two takeaways I use constantly:
- If you see
cmath.rect(1, theta), think “unit-length rotation by theta.” - If you see
cmath.exp(1j * theta), think the same thing (different spelling).
Why have both? rect reads nicely when you’re explicitly in polar; exp(1j*theta) reads nicely when you’re doing signal math.
Complex Operations You Should Know Early (Conjugate, Reciprocal, Powers)
Once you’re comfortable with +, *, abs, and phase, a few more operations unlock a lot of practical workflows.
Conjugate: z.conjugate()
The complex conjugate of x + yj is x - yj. It reflects a point across the real axis.
It’s also one of the cleanest ways to compute a “safe” denominator for division:
z = 2 + 3j
conj = z.conjugate()
print(conj) # 2-3j
print(z * conj) # (x^2 + y^2) as a real number
That last property is not a coincidence:
(x + yj)(x - yj) = x^2 + y^2
So z * z.conjugate() is the squared magnitude (a real value). You’ll see this idea in signal processing and complex least squares.
Reciprocal and division
You can divide directly in Python (z1 / z2). It works, but it’s still useful to understand the shape of the result:
- Division can amplify noise when
is small.z2 - If you’re normalizing values, sometimes you want to guard against tiny magnitudes.
A simple defensive pattern:
def safediv(znum: complex, z_den: complex, *, eps: float = 1e-12) -> complex:
if abs(z_den) < eps:
raise ZeroDivisionError(f‘denominator magnitude too small: {abs(z_den)}‘)
return znum / zden
Powers and roots
Python supports complex exponents with and with cmath.pow.
import cmath
z = 1 + 1j
print(z 2)
print(cmath.sqrt(z))
One thing I always keep in mind: complex roots have multiple valid answers in math. cmath.sqrt returns the principal square root (the conventional choice), which is consistent, but not the only possible root.
math vs cmath: Choosing the Right Toolbox
The math module is for real-valued math. The cmath module is for complex-valued math.
That’s not just “a rule,” it affects behavior:
import math
import cmath
# print(math.sqrt(-1)) # ValueError
print(cmath.sqrt(-1)) # 1j
My practical guideline in mixed codebases:
- Use
mathwhen you can guarantee inputs are real and you want real outputs. - Use
cmathwhen complex values might appear, or when you want consistent behavior across real/complex.
A subtle point: some functions in cmath will return a complex result even when the imaginary part is 0j. If downstream code expects a float, that can be annoying.
Two ways I handle that:
1) Keep the entire pipeline complex (my preference if the domain is truly complex-valued).
2) Convert at the boundary:
def tofloatif_real(z: complex, *, tol: float = 0.0) -> float | complex:
if abs(z.imag) <= tol:
return float(z.real)
return z
Parsing, Serialization, and Data Contracts (Where Most Bugs Actually Happen)
Arithmetic is the easy part. The real-world pain tends to show up at boundaries: reading input, writing output, sending JSON, storing in databases, or accepting user input.
Parsing with complex(‘...‘): convenient but sharp
Python can parse strings like ‘1+2j‘:
z = complex(‘1+2j‘)
print(z)
But in production systems, I don’t treat that as a stable interface unless I control the format end-to-end. Issues I’ve seen:
- Whitespace quirks and alternate spellings (
‘1 + 2j‘vs‘1+2j‘) - Locale and decimal separators in user input
- Special values (
‘nan‘,‘inf‘) sneaking into logs and breaking analytics - Mixed representations across services
A safer contract: explicit parts
If I’m designing an API or file format, I usually serialize complex numbers as explicit real/imag parts.
Recommended options:
- JSON object:
{‘real‘: 1.0, ‘imag‘: 2.0} - JSON array:
[1.0, 2.0]
Then conversion is trivial and unambiguous:
def from_parts(obj: dict) -> complex:
return complex(float(obj[‘real‘]), float(obj[‘imag‘]))
def to_parts(z: complex) -> dict:
return {‘real‘: float(z.real), ‘imag‘: float(z.imag)}
If your domain is naturally polar (magnitude + phase), store that instead:
import cmath
def to_polar(z: complex) -> dict:
r, phi = cmath.polar(z)
return {‘r‘: float(r), ‘phi‘: float(phi)}
def from_polar(obj: dict) -> complex:
return cmath.rect(float(obj[‘r‘]), float(obj[‘phi‘]))
The important part is not which you choose—it’s that you choose one and document it.
Handling Edge Cases: Zero, NaN, Infinity, and Signed Zero
Complex arithmetic inherits floating-point corner cases. Most of the time you can ignore them—until you can’t.
Zero magnitude and undefined phase
The phase of 0+0j is not meaningful (there is no angle for the origin). In Python:
import cmath
z = 0 + 0j
print(abs(z))
print(cmath.phase(z))
The phase is typically reported as 0.0 by convention, but you should not build logic that assumes it represents a real direction.
If “phase matters” in your application, guard it:
def phaseifdefined(z: complex, *, eps: float = 1e-12) -> float | None:
if abs(z) < eps:
return None
import cmath
return cmath.phase(z)
NaN and infinity
If either component becomes NaN, it tends to poison calculations downstream. Same for infinity.
I like to add validation at key boundaries:
import math
def isfinitecomplex(z: complex) -> bool:
return math.isfinite(z.real) and math.isfinite(z.imag)
If you’re debugging “why did everything become NaN,” this is often the fastest way to isolate the first bad value.
Signed zero (-0.0)
Floats can have -0.0. That can show up in z.imag after certain operations and it can make logs confusing ((1-0j) vs (1+0j)). Numerically they’re equal, but if you care about clean display, normalize tiny values:
def clean_zero(z: complex, *, eps: float = 0.0) -> complex:
r = 0.0 if abs(z.real) <= eps else z.real
i = 0.0 if abs(z.imag) <= eps else z.imag
return complex(r, i)
Phase Unwrapping: Making Angles Behave Over Time
If you track phase across a sequence (like samples over time), raw phase values can “jump” at the -pi/pi boundary.
Example: imagine a phase that increases smoothly: 3.10, 3.12, 3.13, then it wraps to -3.14, -3.12, … That wrap is representational, not physical.
For arrays, many people use numpy.unwrap. If you want a simple streaming unwrapping routine without any third-party dependencies, you can do it incrementally:
import math
def unwrapphase(prevunwrapped: float, prev_wrapped: float, wrapped: float) -> float:
# Map the delta into [-pi, pi] and accumulate.
delta = wrapped – prev_wrapped
while delta > math.pi:
delta -= 2 * math.pi
while delta < -math.pi:
delta += 2 * math.pi
return prev_unwrapped + delta
Usage pattern:
import cmath
phases_wrapped = [cmath.phase(z) for z in samples]
unwrapped = []
if phases_wrapped:
unwrapped.append(phases_wrapped[0])
for k in range(1, len(phases_wrapped)):
unwrapped.append(unwrapphase(unwrapped[-1], phaseswrapped[k-1], phases_wrapped[k]))
The value here is not “the one true algorithm” but the concept: keep phase continuous when your domain expects it.
Practical Scenario 1: Rotating Points Cleanly (and Composing Rotations)
Earlier I showed rotation with a single multiplication. The bigger win is composability.
If you rotate a point by theta1 then theta2, you can either:
- apply two rotations, or
- multiply the rotators once and reuse it
import cmath
import math
def rotate(z: complex, theta: float) -> complex:
return z * cmath.rect(1.0, theta)
z = 3 + 4j
theta1 = math.radians(20)
theta2 = math.radians(-15)
z_a = rotate(rotate(z, theta1), theta2)
combined = cmath.rect(1.0, theta1) * cmath.rect(1.0, theta2)
z_b = z * combined
print(abs(za – zb))
This is one of my favorite “it’s hard to screw up” patterns: rotation becomes multiplication, and composition becomes multiplication too.
Practical Scenario 2: Sinusoids, Phasors, and Phase Offsets
A real sinusoid like Acos(omegat + phi) can be represented with a complex exponential.
I won’t pretend you need this for every script, but when you do signal math, it’s a superpower because:
- adding sinusoids becomes adding complex phasors
- phase offsets become multiplication by a unit complex number
Here’s a small, concrete example: combine two cosine waves of the same frequency but different phase.
import cmath
import math
A1, phi1 = 2.0, math.radians(10)
A2, phi2 = 1.5, math.radians(-25)
# Represent each cosine as the real part of a complex phasor.
p1 = A1 cmath.exp(1j phi1)
p2 = A2 cmath.exp(1j phi2)
p_sum = p1 + p2
Asum = abs(psum)
phisum = cmath.phase(psum)
print(‘Combined amplitude:‘, A_sum)
print(‘Combined phase (deg):‘, math.degrees(phi_sum))
What I like about this approach is that it separates concerns cleanly:
- the frequency term lives elsewhere (
exp(jomegat)) - the amplitude/phase bookkeeping becomes plain complex addition
Practical Scenario 3: Impedance (AC Circuits) as a One-Liner
In AC analysis, impedance is naturally complex: Z = R + jX.
That’s a perfect fit for Python’s complex type:
def impedance(R: float, X: float) -> complex:
return complex(R, X)
Then:
- series combination is addition
- magnitude and phase are
abs(Z)andphase(Z)
import cmath
import math
Z = impedance(10.0, 5.0)
print(‘Z:‘, Z)
print(‘
:‘, abs(Z))
print(‘angle (deg):‘, math.degrees(cmath.phase(Z)))
Even if you’re not doing circuit theory, this is a good mental model: complex numbers naturally represent “two coupled components where magnitude and angle matter.”
Common Mistakes I See (and How I Avoid Them)
Complex numbers are friendly in Python, but a few pitfalls repeat across teams.
1) Writing 5 + 3i instead of 5 + 3j
Python does not recognize i as the imaginary unit. Use j.
Bad:
# SyntaxError
z = 5 + 3i
Good:
z = 5 + 3j
2) Accidentally creating a name error because you wrote j without a coefficient
This one is subtle. j by itself is not valid; you need 1j.
# NameError: name ‘j‘ is not defined
z = 2 + j
Correct:
z = 2 + 1j
3) Using math instead of cmath
Example: math.sqrt(-1) fails, but cmath.sqrt(-1) works.
import cmath
print(cmath.sqrt(-1)) # 1j
4) Assuming phase won’t jump
If your system tracks phase over time (signals, control loops), raw phase values can jump at the -pi/pi boundary.
If you need a “continuous” phase for plotting or control logic, unwrap it (via an incremental routine, or with array tooling).
5) Treating complex string parsing as a public interface
complex(‘1+2j‘) is convenient, but it’s not a robust interface unless you control the string format.
If you’re building an API or a file format, I recommend you serialize complex numbers explicitly as either:
{‘real‘: 1.0, ‘imag‘: 2.0}[1.0, 2.0]- or polar
{‘r‘: ..., ‘phi‘: ...}
That avoids locale issues, whitespace quirks, and surprises like nan/inf handling.
When Complex Numbers Are the Right Tool (and When They Aren’t)
I reach for complex numbers when the domain naturally has two coupled components that behave like a vector with rotation and scaling.
Good fits:
- 2D geometry: rotations, reflections, scaling in a plane
- Signals: amplitude/phase representation, frequency-domain work
- Control and filters: transfer functions and frequency response
- Electrical engineering: impedance and AC circuit calculations
- Fractals and iterative maps (where complex iteration is the definition)
Places I avoid them:
- General 2D data modeling when readability matters more than algebra (for example, UI layout coordinates). A small
dataclasswithxandycan communicate intent better. - Situations where each component has different units or semantics (for example,
temperature + pressure*jis technically allowed but conceptually confusing).
My rule of thumb: if you find yourself repeatedly passing around (x, y) pairs and applying the same trig-based transformations, complex numbers will likely make the code simpler and less error-prone.
Performance Notes and Interop in 2026 Python Codebases
Complex numbers are fast enough for many workloads because they’re implemented in C under the hood. Still, there are performance and tooling realities worth knowing.
Scalars vs arrays
For single values or small loops, built-in complex + cmath is usually plenty.
For large arrays (tens of thousands to millions of points), you typically move to NumPy and use its vectorized complex arrays (dtype=complex64 or complex128). That’s where you get big wins because you avoid Python-level loops.
Typical overheads you’ll actually notice
cmath.polarandcmath.phaseinvolve trig and can be heavier than basic arithmetic.- Converting back and forth between polar and rectangular in tight loops can add noticeable cost.
In performance-sensitive paths, I recommend you pick one representation as “native” for the computation and convert only at the edges.
Typing and clarity
In modern Python, type hints help readers (and tools) understand what you mean.
def impedance(resistance: float, reactance: float) -> complex:
return complex(resistance, reactance)
If you’re building a library, I also like adding tiny helper functions that name the domain concept:
def rectfromxy(x: float, y: float) -> complex:
# Domain naming: callers think in x/y, code stores a complex.
return complex(x, y)
That way your core logic stays clean (z1 + z2, abs(z), phase(z)), while callers still get an API that reads like the problem space.
Debugging numeric code with quick plots
Even when I’m not doing “data science,” I’ll often make a tiny plot to sanity-check complex behavior:
- plot points
(z.real, z.imag)to see rotation direction - plot
abs(z)over time to verify magnitude invariants - plot unwrapped phase to spot wrap-related jumps
You don’t need elaborate tooling; the key is to visualize the plane and confirm your mental model.
Testing Patterns That Catch Complex Bugs Early
Complex-number code is notorious for “it works for one example” failures. Two lightweight testing approaches have saved me a lot of time.
1) Property-style tests (even without extra libraries)
Instead of asserting one hard-coded output, assert invariants.
Rotation preserves magnitude:
import cmath
import math
import random
def rotate(z: complex, theta: float) -> complex:
return z * cmath.rect(1.0, theta)
for _ in range(1000):
z = complex(random.uniform(-10, 10), random.uniform(-10, 10))
theta = random.uniform(-math.pi, math.pi)
z2 = rotate(z, theta)
assert abs(abs(z2) – abs(z)) < 1e-9
Round-trip polar conversion preserves the value (within tolerance):
import cmath
import random
for _ in range(1000):
z = complex(random.uniform(-10, 10), random.uniform(-10, 10))
r, phi = cmath.polar(z)
z2 = cmath.rect(r, phi)
assert abs(z2 – z) < 1e-9
2) “Golden” edge cases
I like to explicitly test the cases that break intuition:
- purely real (
3+0j) - purely imaginary (
0+3j) - negative real axis (
-3+0j) where phase is nearpi - origin (
0+0j) - tiny magnitudes (
1e-15 + 1e-15j)
These tests don’t prove correctness, but they quickly catch sign errors and angle-wrap surprises.
Key Takeaways and Next Steps
Complex numbers in Python are one of those features that quietly remove entire categories of bugs. When you represent a coupled pair (x, y) as a single value z = x + yj, you stop juggling parallel variables and start writing algebra that matches the math you intended.
If you’re getting started, I recommend you practice these basics until they feel automatic:
- Create complex values with
complex(x, y)when parts come from variables, and withx + yjwhen you’re writing constants. - Read parts with
z.realandz.imag(attributes, not calls). - Treat
abs(z)as magnitude andcmath.phase(z)as the angle in radians. - Convert forms with
cmath.polar(z)andcmath.rect(r, phi), and expect tiny floating-point artifacts on round-trips.
For practical next steps, pick one real problem you care about—rotating points, representing a sinusoid, converting rectangular coordinates to polar, or combining two phasors—and write a short script that logs both forms side by side. If your output surprises you, that’s a gift: it usually means you’ve found an assumption worth making explicit in your code.


