Read the file line by line with enumerate(f, start=1), split each line on whitespace with bare .split(), convert every token inside a try, and compare each row's width against the first row you accepted — then raise an error that carries the line number. That last part is the whole job. Turning a text file into a list of lists takes four lines; making it tell you which line was wrong is what separates a program someone can use from one that makes them read your source.
Update, September 2026: The program has since been changed to do what this piece argues for: validation at read time with the offending line named, any path accepted rather than four hardcoded filenames, and the square-only properties — symmetry, determinant, transpose — added behind a gate. What follows describes the code as it stood.
I wrote the original version of this as a small exercise: read a matrix from a text file, report what you can about it. The maths was never the difficulty. The input is a file a human typed, and that fact quietly becomes most of the program.
What the file format actually is#
The repo has four test files, m1.txt through m4.txt. There is no header, no dimension line, no delimiter declaration. m1.txt is two rows of four numbers:
3 2 4 5
4 6 9 8The others are 8×5, 3×4 and 6×2. Worth noticing straight away: not one of the four is square. Any property that needs a square matrix — determinant, inverse, symmetry — is undefined for every single test file in the repo.
Two of them are also zero-padded to a fixed width:
069 408 556 985
201 982 200 659
785 458 658 005That is a person lining up columns by eye, not a machine writing data. int("069") gives you 69 without complaint, so it parses fine — but it is a reminder of what the format really is. It is "whatever a person would type if you asked them for a matrix", which is a reasonable format for humans and an irritating one for programs, because everything you want to know has to be discovered. The row count is the line count, but only if no line is blank. The column count is the token count of the first row, and nothing guarantees the other rows agree.
The parser, with the line number in the error#
Here is the version I would defend. It is deliberately boring, and every branch in it exists because of a way real files go wrong.
from dataclasses import dataclass
class MatrixFileError(ValueError):
"""A matrix file could not be read. Carries the offending line."""
def __init__(self, path, lineno, message):
self.path, self.lineno = path, lineno
super().__init__(f"{path}:{lineno}: {message}")
@dataclass(frozen=True)
class Matrix:
rows: tuple[tuple[float, ...], ...]
@property
def shape(self) -> tuple[int, int]:
return len(self.rows), len(self.rows[0])
@property
def is_square(self) -> bool:
n, m = self.shape
return n == m
def read_matrix(path) -> Matrix:
rows: list[tuple[float, ...]] = []
width: int | None = None
width_lineno = 0
with open(path, encoding="utf-8") as f:
for lineno, line in enumerate(f, start=1):
tokens = line.split()
if not tokens:
continue # blank line, or the newline ending the file
values = []
for column, token in enumerate(tokens, start=1):
try:
values.append(float(token))
except ValueError:
raise MatrixFileError(
path, lineno,
f"column {column}: {token!r} is not a number",
) from None
if width is None:
width, width_lineno = len(values), lineno
elif len(values) != width:
raise MatrixFileError(
path, lineno,
f"got {len(values)} values, expected {width} "
f"(set by line {width_lineno})",
)
rows.append(tuple(values))
if not rows:
raise MatrixFileError(path, 0, "file contains no numbers")
return Matrix(tuple(rows))The messages it produces:
m1.txt:2: got 3 values, expected 4 (set by line 1)
m1.txt:2: column 3: 'x' is not a number
m1.txt:1: column 1: '3,5' is not a numberThree details carry their weight. enumerate(f, start=1) means the number in the message is the number the user's editor shows — off-by-one here is worse than no number at all. Naming both lines in the ragged-row message matters because the offender is ambiguous: if line 1 has four values and line 2 has three, you cannot know which one the author fluffed, so report both and let them look. And from None suppresses the chained ValueError: could not convert string to float, which adds nothing once you have said the same thing with a location.
Ragged rows are the failure that does not crash#
The most likely mistake in a hand-typed matrix file is a row with the wrong number of entries. It is also the one that punishes you most for not checking, and my original code is the demonstration. It did this:
for i in lines:
records.append(i.split(" "))Feed that a file where row 2 is short and nothing raises. The conversion to int happens later, in a different module, and it succeeds — every token is still a number. The program prints:
Matrix size is : 2 x 3for a file whose first row has four entries. Not a crash, not a warning: a confident, wrong answer. The size came from len(records[1]), the length of the second row, which also means a single-row file dies with IndexError: list index out of range from inside a function called matrixSize — a message with nothing in it about files, lines, or matrices.
split(" ") is the other bug in those two lines, and it is the more instructive one. Splitting on a literal single space is not the same as splitting on whitespace. Two spaces between numbers produce an empty string token, and so does a blank line, and so does a file that ends with a blank line. All three give you:
ValueError: invalid literal for int() with base 10: ''Tabs fail differently and worse — "3\t2\t4\t5".split(" ") is a one-element list, so you get invalid literal for int() with base 10: '3\t2\t4\t5'. Bare .split() splits on any run of whitespace and discards empties, which is exactly the behaviour a hand-typed file needs. It is a two-character fix that removes four failure modes.
This is the general principle the whole post rests on: validate at the boundary, where you still know the context. Once the file has become a list of lists, the line number is gone, and every error downstream is worse for it.
Should you just use numpy.loadtxt instead?#
Often, yes. If you are already in NumPy and the file is machine-written, np.loadtxt is the right answer and writing your own parser is not a virtue. But the argument for the stdlib version is narrower and more specific than "avoid dependencies", so here is what NumPy 2.5 actually does with a broken file.
Ragged input to np.loadtxt:
ValueError: the number of columns changed from 3 to 2 at row 2; use `usecols` to select a subset and avoid this errorThat is a good message — it names the line, and row 2 really is file line 2. np.genfromtxt also names it, in its own style: Some errors were detected ! followed by Line #2 (got 2 columns instead of 3).
Now a non-numeric token:
ValueError: could not convert string 'zz' to float64 at row 2, column 3.The token is on file line 3. In that message row counts from zero while column counts from one, and it is the same function that counted from one a moment ago in the ragged-row message. I checked this across four positions to be sure it was not a fluke; it is not. So the honest version of the argument is not that NumPy's errors are bad — they are decent. It is that they are not yours. You cannot make them say m1.txt:3 in the format the rest of your tool uses, you cannot add the hint that actually helps this user, and you inherit an indexing convention that disagrees with itself.
The worse trap is np.genfromtxt, which does not raise on that file at all. It returns the matrix with nan in the offending cell and lets you carry on. Every arithmetic result downstream is then nan, and you find out about the typo somewhere much less convenient. The top search results for the loadtxt ragged error mostly suggest reaching for usecols to make the error go away — which does not fix the file, it silently drops columns. That is precisely the shape of advice that validating at the boundary exists to resist.
Square is a precondition, not a property#
The original program stopped at summary statistics: size, largest and smallest value, sum, product, first and last element, odds and evens. The moment you go further and add the things people actually want from a matrix, the shape of the program has to change.
The determinant, the inverse and symmetry are only defined for square matrices. So is_square is not a fact you print in a list alongside the others — it is a gate that decides which of the others can be computed at all. Checking it first and branching is clearer than computing everything and catching exceptions, and the report for a 2×4 matrix should say the determinant is undefined for non-square input rather than surfacing a traceback from three functions down. Given that none of the four test files is square, that branch is not an edge case in this repo. It is the only branch that ever runs.
Symmetry needs a tolerance, and math.isclose has a trap near zero#
Symmetry means A[i][j] == A[j][i]. For integers read off disk that is exact, and the naive check is correct.
It stops being correct the moment any operation introduces floats — a matrix built from a decomposition, or a product of computed values. Two mathematically identical values can differ in the last bit, and == will cheerfully tell you a symmetric matrix is not symmetric. The fix is a tolerance, and this is where the detail matters:
math.isclose(a, b, *, rel_tol=1e-09, abs_tol=0.0)abs_tol defaults to zero, and the relative test has nothing to be relative to when both values are near zero. So math.isclose(0.0, 1e-300) is False, and so is math.isclose(1e-12, 2e-12). If your matrix has small off-diagonal entries — and a matrix that is nearly symmetric usually does — the default is the wrong tool, and you have to pass abs_tol yourself.
NumPy chose different defaults:
np.allclose(a, b, rtol=1e-05, atol=1e-08, equal_nan=False)Both of those are looser, and crucially atol is non-zero, so np.isclose(0.0, 1e-300) is True and np.isclose(1e-12, 2e-12) is True. Neither set of defaults is wrong; they are answering different questions. The mistake is assuming they agree, or reaching for math.isclose on near-zero data without setting abs_tol. For symmetry specifically, np.allclose(A, A.T) is the check you want.
The determinant is the wrong tool for testing singularity#
Two things about determinants that the textbook version leaves out.
The first is cost. Cofactor expansion — the method you are taught, expanding along a row into minors — is Θ(n!). For a 20×20 matrix that is 2,432,902,008,176,640,000 terms. LU decomposition is O(n³), which for the same matrix is 8,000. That is not an optimisation, it is the difference between a result and a hung process, and it is why np.linalg.det does not use cofactors. Its docstring is explicit: "The determinant is computed via LU factorization using the LAPACK routine z/dgetrf."
The second is that a determinant near zero does not mean a matrix is near singular. Determinants scale with the size of the matrix: multiply an n×n matrix by c and the determinant is multiplied by cⁿ. So take a perfectly conditioned matrix, 0.1 * np.eye(20):
>>> A = 0.1 * np.eye(20)
>>> np.linalg.det(A)
1.0000000000000135e-20
>>> np.linalg.cond(A)
1.0
>>> np.linalg.matrix_rank(A)
20A determinant of 10⁻²⁰, and the matrix is as well-behaved as a matrix can be — condition number exactly 1, full rank. Any threshold like if abs(det) < 1e-12: print("singular") calls that matrix singular and is simply wrong. Use np.linalg.cond or np.linalg.matrix_rank instead. And if you need the determinant of a large matrix at all, np.linalg.slogdet returns the sign and log-magnitude separately, which is what stops the underflow that produced that 10⁻²⁰ in the first place.
Validating at the boundary in bigger programs#
The principle scales, and modern Python has good machinery for it. The point is to have one place where untrusted text becomes a trusted object, and to make it impossible to get the object without going through it.
A frozen dataclass with a __post_init__ that raises is enough for most cases, and it is what the Matrix above is one step away from: put the width check in __post_init__ and the type itself guarantees rectangularity, so nothing downstream has to re-check. For anything with real structure — config files, API payloads — Pydantic v2 (2.13 at the time of writing) is the standard answer, and its framing is the same one: parse, don't validate. You do not check a dict and pass the dict along; you convert it into a type that cannot represent the invalid state, once, at the edge.
It is the same instinct that made me put reference data in Postgres as closed sets defined by seeders rather than free-text strings — constrain it where it enters, and everything downstream gets easier.
One caveat, because the right answer is not always "raise". When I built a Playwright bot that walks a CSV of rate changes, a malformed date in row 12 of a 200-row file marks that row failed, logs why, and carries on — aborting the run would be useless behaviour for a tool operating on a hand-maintained file. A matrix file is the opposite case: it is a single value, not 200 independent ones, and there is no such thing as most of a matrix. Validating at the boundary tells you where the problem is. Whether to stop there is a separate decision, and it depends on whether the rows are independent.
Why small programs are worth writing carefully#
There is no algorithmic difficulty in any of this. What the project rewards is the discipline of treating input as hostile, failing with messages that name the problem, and being explicit about preconditions instead of letting them fail three functions downstream.
I did not have those habits when I wrote the original, which is why split(" ") and len(records[1]) are both still sitting in that repo — two lines, two bugs, one of which returns a wrong answer rather than crashing. That is the useful part. Those habits do not come from large systems, where a bad line is buried under enough abstraction that you can blame the framework. They come from small ones, where the whole program fits in your head and there is nowhere for a shortcut to hide.
Common questions#
How do I read a matrix from a text file in Python without NumPy?#
Open the file, iterate it with enumerate(f, start=1), call bare .split() on each line, skip lines that produce no tokens, and convert each token with float() inside a try. Keep the width of the first row you accepted and compare every later row against it. That is the entire parser, and the validation is more code than the parsing.
Why does my parser break on blank lines or double spaces?#
Almost always because you used .split(" ") instead of .split(). Splitting on a literal space returns empty-string tokens for runs of whitespace and for blank lines, and int("") raises ValueError: invalid literal for int() with base 10: ''. Bare .split() splits on any whitespace run and discards empties, which also fixes tab-separated files for free.
What error does numpy.loadtxt give for ragged rows?#
ValueError: the number of columns changed from 3 to 2 at row 2; use usecols to select a subset and avoid this error, where the row number is the 1-indexed file line. Note that its conversion error — could not convert string 'zz' to float64 at row 2, column 3. — counts rows from zero instead. Be careful with np.genfromtxt: it does not raise on non-numeric tokens at all, it substitutes nan.
Should I use math.isclose or np.allclose to check symmetry?#
np.allclose(A, A.T) if you are already using NumPy. Its defaults are rtol=1e-05, atol=1e-08. math.isclose defaults to rel_tol=1e-09, abs_tol=0.0, and that zero absolute tolerance makes it return False for genuinely tiny values — math.isclose(0.0, 1e-300) is False. If you use it on data near zero, pass abs_tol explicitly.
Is a small determinant a good test for a singular matrix?#
No. Determinants scale as cⁿ, so 0.1 * np.eye(20) has a determinant of about 10⁻²⁰ while having a condition number of exactly 1 and full rank. Use np.linalg.cond or np.linalg.matrix_rank to test for singularity, and np.linalg.slogdet if you need the determinant of a large matrix without underflowing.