Skip to the page
Tecyfy

PySpark DataFrames: read CSV files with a schema and keep the bad rows

A PySpark DataFrame is a table of named, typed columns that describes a computation instead of holding the data. Each transformation, such as filter, withColumn or join, adds a step to a plan, and nothing runs until an action, such as show, count or write, asks for a result. Spark then optimises the whole plan and runs it, on your laptop or across a cluster, with the same code.

Module 2: PySpark DataFrames

At the end of this part you can:

  • Start Spark on your laptop or on Databricks Free Edition, with the module's data
  • Read CSV files with a schema, and keep the rows that do not parse instead of losing them
  • Save what you read before you split it, in a way that also works on serverless
  • Read a query plan with explain() and say what Spark will read

Take this module into your AI

Open in ChatGPT (new tab)Open in Claude (new tab)

Most data engineering code is a handful of moves repeated: read a file, fix its types, throw out what is broken, keep the newest version of each row, join it to something, add it up and write the result. This module makes those moves in PySpark, the Python interface to Apache Spark, on a small set of orders from an online shop. The module has 5 parts, and by the end of the last you have the first step of the course's pipeline: a script that turns the shop's raw order files into clean tables, with the rows it rejected kept for someone to fix. This part sets up, reads the files and shows how Spark plans the work.

The data is small on purpose. You can see every row, so you can check every answer by eye. The same code runs on a billion rows across a cluster, which is the point of Spark: you describe what you want once, and Spark decides how to spread the work.

Set up

You can run this module on your own computer or on Databricks Free Edition. Both are free.

On your computer you need Python 3.10 or later and Java 17, 21 or 25, because Spark runs on the Java virtual machine and PySpark talks to it. Install PySpark with its SQL extras, which bring pandas and PyArrow:

pip install "pyspark[sql]==4.2.0"

On Databricks Free Edition, create a notebook. It runs on serverless compute, and a Spark session called spark is already there, so skip the session step below.

Now make the data. This script writes 2 small files, the shop's orders and its customers. Read the orders closely, because each problem in them is there for a reason: order 1001 and 1002 change status, so they appear more than once; order 1003 has an amount of abc; order 1004 is in the file twice, exactly the same; and order 1005 belongs to customer C09, who is not in the customers file.

from pathlib import Path

RAW = Path("raw")  # on Databricks: Path(f"/Volumes/{catalog}/{schema}/raw")
RAW.mkdir(parents=True, exist_ok=True)

(RAW / "orders.csv").write_text("""order_id,customer_id,status,amount,currency,ordered_at,updated_at
1001,C01,placed,25.50,EUR,2026-10-01T09:15:00,2026-10-01T09:15:00
1002,C02,placed,12.00,EUR,2026-10-01T10:02:00,2026-10-01T10:02:00
1001,C01,shipped,25.50,EUR,2026-10-01T09:15:00,2026-10-02T08:00:00
1003,C03,placed,abc,EUR,2026-10-02T11:30:00,2026-10-02T11:30:00
1004,C01,placed,40.00,EUR,2026-10-02T12:45:00,2026-10-02T12:45:00
1002,C02,cancelled,12.00,EUR,2026-10-01T10:02:00,2026-10-03T09:00:00
1005,C09,placed,8.75,EUR,2026-10-03T14:10:00,2026-10-03T14:10:00
1004,C01,placed,40.00,EUR,2026-10-02T12:45:00,2026-10-02T12:45:00
""")
(RAW / "customers.csv").write_text("""customer_id,name,country
C01,Ana,PT
C02,Ben,IE
C03,Chen,DE
""")

On Databricks, the files go in a Unity Catalog volume, a governed folder for files. Create one in the catalog and schema your notebook is using, then run the script above with RAW set to the volume's path:

catalog = spark.catalog.currentCatalog()
schema = spark.catalog.currentDatabase()
spark.sql(f"CREATE VOLUME IF NOT EXISTS {catalog}.{schema}.raw")

A volume has an ordinary file path, /Volumes/<catalog>/<schema>/<volume>, so plain Python can write into it. If you prefer, upload the 2 files instead, from New, then Add or upload data, then Upload files to a volume. Asking Spark for the current catalog, rather than typing a name, means the code works in any workspace.

Last, start Spark. On your computer this starts a local Spark that uses every core:

from pyspark.sql import SparkSession
from pyspark.sql import functions as F

spark = SparkSession.builder.appName("orders").getOrCreate()
RAW, LAKE = "raw", "lake"  # where the files are, and where this module writes

Importing the functions module as F is the usual convention. Almost everything you do to a column, from F.col to F.sum, is in it. On Databricks, run only the import line and set RAW to your volume's path.

A DataFrame is a plan, not the data

The first thing to understand about Spark changes how you read every line after it. When you write a transformation, Spark does not do it. It writes it down.

A transformation is anything that returns a new DataFrame: select, filter, withColumn, join, groupBy. An action is anything that needs an answer: show, count, collect, write. Spark is lazy. Transformations only add steps to a plan, and the first action makes Spark read the data and run the whole plan at once.

That is not a quirk; it is why Spark is fast. Because it sees the whole plan before it starts, Spark can read only the columns the plan uses, apply filters while it reads the file instead of after, and choose how to run each join. You will see the first 2 in a plan below, and the third in part 3. It also means a mistake in a line can show up later, at the action, which takes some getting used to.

Read files with a schema

The quick way to read a CSV asks Spark to guess the types:

guess = spark.read.csv(f"{RAW}/orders.csv", header=True, inferSchema=True)
guess.printSchema()
root
 |-- order_id: integer (nullable = true)
 |-- customer_id: string (nullable = true)
 |-- status: string (nullable = true)
 |-- amount: string (nullable = true)
 |-- currency: string (nullable = true)
 |-- ordered_at: timestamp (nullable = true)
 |-- updated_at: timestamp (nullable = true)

Look at amount. It is a string, because a single value of abc does not fit a number, and inference picks the type every value fits. Any sum you write on it now is wrong or fails. Inference also reads the file an extra time just to guess, which on a large file is a whole extra pass.

In a pipeline, give the schema yourself. A DDL string, the same column list a CREATE TABLE takes, is the shortest way:

ORDERS = """
    order_id BIGINT, customer_id STRING, status STRING, amount DECIMAL(10,2),
    currency STRING, ordered_at TIMESTAMP, updated_at TIMESTAMP, _bad STRING
"""
orders = (
    spark.read.schema(ORDERS)
    .option("header", True)
    .option("mode", "PERMISSIVE")
    .option("columnNameOfCorruptRecord", "_bad")
    .csv(f"{RAW}/orders.csv")
)
orders.select("order_id", "amount", "_bad").show(truncate=False)
+--------+------+---------------------------------------------------------------+
|order_id|amount|_bad                                                           |
+--------+------+---------------------------------------------------------------+
|1001    |25.50 |NULL                                                           |
|1002    |12.00 |NULL                                                           |
|1001    |25.50 |NULL                                                           |
|1003    |NULL  |1003,C03,placed,abc,EUR,2026-10-02T11:30:00,2026-10-02T11:30:00|
|1004    |40.00 |NULL                                                           |
|1002    |12.00 |NULL                                                           |
|1005    |8.75  |NULL                                                           |
|1004    |40.00 |NULL                                                           |
+--------+------+---------------------------------------------------------------+

Money is a DECIMAL, never a DOUBLE, because a double cannot hold 0.10 exactly and the cents drift when you add up millions of rows.

The 2 options are how you keep bad rows instead of losing them. PERMISSIVE is the default mode: when a line does not parse, Spark sets the fields it could not read to null, and if the schema has a string column with the name you give columnNameOfCorruptRecord, it puts the whole original line there. Order 1003 is now visible, with the exact text that was wrong. The other modes are worse for a pipeline: DROPMALFORMED drops the line without telling you, and FAILFAST stops the whole read at the first bad line.

Try itRead a file with a broken line
spark.read.csv(path, header=True, inferSchema=True)
amountstringrows81 extra read to guess
lineorder_idamount
11001"25.50"
21002"12.00"
31001"25.50"
41003"abc"
51004"40.00"
61002"12.00"
71005"8.75"
81004"40.00"

Spark read the whole file once to guess, and abc does not fit a number, so amount is a string. Every line fits a string, so nothing counts as malformed and PERMISSIVE changes nothing: all 8 rows come back, and a sum of amount is now a sum of text.

The 8 lines of the module's orders.csv. Each result is what Spark 4.2 returned for that choice.

Save what you read, then split it

You might expect to count the bad rows straight away. Try it, and Spark refuses:

orders.filter(F.col("_bad").isNotNull()).count()
AnalysisException: [UNSUPPORTED_FEATURE.QUERY_ONLY_CORRUPT_RECORD_COLUMN] The feature is not
supported: Queries from raw JSON/CSV/XML files are disallowed when the referenced columns only
include the internal corrupt record column

Spark's message suggests caching the DataFrame first. Do not follow it: caching is not available on Databricks serverless, as part 4 explains. The fix that works everywhere is the one a pipeline wants anyway. Save the rows exactly as you read them, then work from the saved copy:

orders.write.mode("overwrite").parquet(f"{LAKE}/bronze_orders")
bronze = spark.read.parquet(f"{LAKE}/bronze_orders")

bad = bronze.filter(F.col("_bad").isNotNull())
good = bronze.filter(F.col("_bad").isNull()).drop("_bad")
print(bad.count(), good.count())
1 7

Parquet is the file format almost every data tool reads: it stores each column together, compressed, with its type, so a query reads only the columns it needs. The saved copy is what the Medallion ArchitectureMedallion ArchitectureOrganising a lakehouse in 3 layers of rising quality: bronze for raw data, silver for cleaned and joined data, and gold for tables shaped for reports and AI. calls bronze, the raw data kept as it arrived. Module 7 builds the full bronze, silver and gold pipeline; for now, keeping the raw copy means you can always rebuild everything after it.

On Databricks, save a table instead of a folder of files. Tables are Delta Lake tables by default, which module 4 covers:

orders.write.mode("overwrite").saveAsTable(f"{catalog}.{schema}.bronze_orders")
bronze = spark.table(f"{catalog}.{schema}.bronze_orders")

Select, filter and see the plan

Here is a short plan: the orders that are still placed, with the day each was made.

placed = (
    good.filter(F.col("status") == "placed")
    .withColumn("order_date", F.to_date("ordered_at"))
    .select("order_id", "customer_id", "amount", "order_date")
)
placed.explain()

Nothing has run yet. explain() prints the plan Spark made, read from the bottom up. Trimmed to its last line, the file scan, it says:

FileScan parquet [order_id#66L,customer_id#67,status#68,amount#69,ordered_at#71,_bad#73]
  PushedFilters: [IsNotNull(status), IsNull(_bad), EqualTo(status,placed)]
  ReadSchema: struct<order_id:bigint,customer_id:string,status:string,amount:decimal(10,2),...

That is the laziness paying off. ReadSchema lists only the columns the plan uses, so currency and updated_at are never read from disk. PushedFilters shows both filters, yours on status and the earlier one on _bad, handed to the Parquet reader, which can skip whole blocks of a file that cannot match. The numbers after each column, such as #66, are Spark's internal ids and change from run to run.

Try itBuild a plan, then run it
Transformations
placed = (
    good
    .filter(F.col("status") == "placed")
    .withColumn("order_date", F.to_date("ordered_at"))
    .select("order_id", "customer_id", "amount", "order_date")
)
bronze_ordersNot read yetColumns read 0 of 7, rows returned 0
order_idcustomer_idstatusamountcurrencyordered_atupdated_at
1001C01placed25.50EUR2026-10-01 09:152026-10-01 09:15
1002C02placed12.00EUR2026-10-01 10:022026-10-01 10:02
1001C01shipped25.50EUR2026-10-01 09:152026-10-02 08:00
1004C01placed40.00EUR2026-10-02 12:452026-10-02 12:45
1002C02cancelled12.00EUR2026-10-01 10:022026-10-03 09:00
1005C09placed8.75EUR2026-10-03 14:102026-10-03 14:10
1004C01placed40.00EUR2026-10-02 12:452026-10-02 12:45

3 transformations, and nothing has been read: the file is untouched until an action asks for a result. Press show().

The 7 rows that parsed in the module's orders, saved as Parquet. The columns read are the ReadSchema of the module's explain().

Now ask for the result:

placed.orderBy("order_id").show()
+--------+-----------+------+----------+
|order_id|customer_id|amount|order_date|
+--------+-----------+------+----------+
|    1001|        C01| 25.50|2026-10-01|
|    1002|        C02| 12.00|2026-10-01|
|    1004|        C01| 40.00|2026-10-02|
|    1004|        C01| 40.00|2026-10-02|
|    1005|        C09|  8.75|2026-10-03|
+--------+-----------+------+----------+

This answer is wrong, and the data says why. Orders 1001 and 1002 have moved on, to shipped and cancelled, but their old placed rows still match. Order 1004 is counted twice. Part 2 fixes both, by keeping only the newest version of each order.

Check yourself

2 questions, each in a new situation

  1. 1. You read orders.csv with inferSchema=True. Its amount column has 2 million numbers and 1 value of "n/a", and Spark makes the column a string. What should you change?

  2. 2. Your notebook on Databricks serverless reads raw JSON with a _bad column for corrupt records, then calls df.cache() so it can count the bad rows, as Spark's own error message suggested. The cell fails. What is the fix that works on serverless?

Questions

What is a DataFrame in PySpark?
A PySpark DataFrame is a table with named columns, each with a type, that Spark can spread across many machines. It does not hold the data the way a pandas DataFrame does. It describes how to compute the data, and Spark runs that description when an action such as show, count or write asks for a result.
What is the difference between a transformation and an action in Spark?
A transformation, such as select, filter, withColumn, join or groupBy, returns a new DataFrame and only adds a step to the plan. An action, such as show, count, collect or write, needs a result, so it makes Spark optimise the whole plan and run it. Nothing is read from disk until the first action.
How do I read a CSV file in PySpark with a schema?
Pass the schema to the reader before the file, as a DDL string such as "order_id BIGINT, amount DECIMAL(10,2)" or as a StructType, with spark.read.schema(schema).option("header", True).csv(path). A schema saves the extra pass over the data that inferSchema makes, and the columns get the types you chose instead of whatever fits every value.
Why does Spark refuse to count the rows of my corrupt record column?
Spark does not allow a query on a raw CSV, JSON or XML file whose columns are only the corrupt record column, and fails with UNSUPPORTED_FEATURE.QUERY_ONLY_CORRUPT_RECORD_COLUMN. Save what you read, as Parquet files or a table, and count the bad rows from the saved copy. Caching also works on a laptop, but not on Databricks serverless.

Sources

Where each fact comes from

  1. 1Spark overview
  2. 2Installation
  3. 3RDD programming guide
  4. 4CSV files
  5. 5DataFrame.explain
  6. 6Serverless compute limitations
  7. 7Databricks Free Edition
  8. 8Work with files in Unity Catalog volumes
  9. 9Work with files on Databricks
  10. 10CREATE VOLUME
  11. 11What is Delta Lake?