Complex Numbers in Python (Set 1): A Practical Introduction

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 uses j for 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 convert x and y to floats internally (when they’re ints), so z.real and z.imag commonly show up as 5.0 and 3.0.
  • If you have a string like ‘5+3j‘, you can do complex(‘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.

Task

Traditional approach

Modern Python approach —

— Build a complex from two floats

Keep (x, y) tuple everywhere

Store as complex(x, y) early Add two “2D values”

x1+x2, y1+y2

z1 + z2 Rotate a 2D point by angle

Manual cos/sin with two equations

Multiply by 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.real
  • z.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):

  • r is the magnitude (same as abs(z))
  • phi is the phase angle (same as cmath.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 z2

    is small.

  • 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 math when you can guarantee inputs are real and you want real outputs.
  • Use cmath when 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) and phase(Z)

import cmath

import math

Z = impedance(10.0, 5.0)

print(‘Z:‘, Z)

print(‘

Z

:‘, 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 dataclass with x and y can communicate intent better.
  • Situations where each component has different units or semantics (for example, temperature + pressure*j is 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.polar and cmath.phase involve 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 near pi
  • 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 with x + yj when you’re writing constants.
  • Read parts with z.real and z.imag (attributes, not calls).
  • Treat abs(z) as magnitude and cmath.phase(z) as the angle in radians.
  • Convert forms with cmath.polar(z) and cmath.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.

Scroll to Top