DEV Community

Cover image for PCAD-31-02 in Code: Validation Checks and Safe sqlite3 Queries Behind 30 of the 48 Items
Mete Kahyagil
Mete Kahyagil

Posted on

PCAD-31-02 in Code: Validation Checks and Safe sqlite3 Queries Behind 30 of the 48 Items

The Python Institute PCAD-31-02 exam has 48 items, and 30 of them come from two syllabus blocks: Data Acquisition and Pre-Processing (14) and Programming and Database Skills (16). If you can clean a messy file, validate it, move it into a database with safe queries and read the result back into pandas, you have practised the skills behind 62.5% of the exam.

This post does all of that in one small script you can run. Every output shown below was produced by running it with Python 3.12 and pandas 3.0.6, using only sqlite3 from the standard library for the database part.

What does the PCAD-31-02 exam look like?

PCAD (Certified Associate Data Analyst with Python) is the associate-level data exam of the OpenEDG Python Institute. The figures below come from the official PCAD certification page, read on 15 October 2026.

Item Value
Exam code PCAD-31-02, active since July 15, 2025 (PCAD-31-01 retired July 14, 2025)
Questions 48 single-select and multiple-select items, some scenario-based
Time 60 minutes, plus the NDA screen
Passing score 75%
Price Exam from $195; exam with retake from $225; practice test $49
Delivery Online through OpenEDG Testing Service (TestNow)
Prerequisites None formal; PCAP and PCED (or equivalent) recommended
Validity 6 years

How are the 48 items split across the five blocks?

The Python Institute publishes the split in the PCAD-31-02 exam syllabus, last updated July 15, 2025. Each item is worth up to one point, and the raw score is normalized to a percentage.

Block Items Weight
1. Data Acquisition and Pre-Processing 14 29.2%
2. Programming and Database Skills 16 33.3%
3. Statistical Analysis 4 8.3%
4. Data Analysis and Modeling 9 18.8%
5. Data Communication and Visualization 5 10.4%

Waffle chart of the 48 PCAD-31-02 exam items, one square per item, coloured by syllabus block: 14 for data acquisition, 16 for programming and databases, 4 for statistics, 9 for analysis and modeling, 5 for visualization

Note what that means for your study plan. Blocks 1 and 2 are the bulk, but 30 of 48 is still below the 75% pass mark, so the statistics, pandas and charting blocks cannot be skipped. The script below covers the large blocks; it also touches objective 4.1.1 (cleaning with pandas) on the way.

Why load every column as text first?

Objective 1.2.2 asks you to identify erroneous data before you fix it. If pandas guesses types on load, a bad value can silently change a whole column's type, and you lose the chance to see which rows were wrong. Loading with dtype=str keeps the raw values so the cleaning step decides what each one means.

Here is a small order file with the problems the syllabus lists by name: stray spaces, mixed case, a thousands separator, a missing value written as text, a negative amount, a duplicate row, a date in the wrong format and five spellings of yes and no.

import io
import sqlite3

import pandas as pd

pd.set_option("display.width", 120, "display.max_columns", 10)   # print tables in full

RAW = """order_id,customer,country,amount,paid,order_date,ship_date
1001,  Ana Lima ,BR,120.50,yes,2026-09-01,2026-09-03
1002,ben okafor,NG,"1,080.00",Y,2026-09-02,2026-09-01
1003,Chen Wei,CN,n/a,no,2026-09-02,2026-09-05
1004,Ana Lima,br,-15,TRUE,2026-09-03,2026-09-04
1004,Ana Lima,br,-15,TRUE,2026-09-03,2026-09-04
1005,Dara Singh,IN,64.00,0,03/09/2026,2026-09-06
1006,Ben Okafor,NG,310.00,1,2026-09-04,2026-09-07
1007,Chen Wei,cn,89.90,yes,2026-09-05,2026-09-05
1008,Dara Singh,IN,455.00,N,2026-09-06,2026-09-09
1009,ana lima,BR,72.25,y,2026-09-07,2026-09-08
"""
df = pd.read_csv(io.StringIO(RAW), dtype=str)
print(len(df), "rows")   # 10 rows
Enter fullscreen mode Exit fullscreen mode

Which cleaning steps does Block 1 name, and what do they look like?

Objective 1.2.4 lists them in one sentence: string manipulation, format standardization, boolean normalization, case normalization and string-to-number conversion. Each one is a single line in pandas.

BOOL = {"yes": True, "y": True, "true": True, "1": True,
        "no": False, "n": False, "false": False, "0": False}

df = df.drop_duplicates()
df["customer"] = df["customer"].str.strip().str.title()
df["country"] = df["country"].str.strip().str.upper()
df["amount"] = pd.to_numeric(df["amount"].str.replace(",", ""), errors="coerce")
df["paid"] = df["paid"].str.strip().str.lower().map(BOOL)
for col in ("order_date", "ship_date"):
    df[col] = pd.to_datetime(df[col], format="%Y-%m-%d", errors="coerce")
df["order_id"] = df["order_id"].astype(int)
print(df.dtypes)
Enter fullscreen mode Exit fullscreen mode
order_id               int64
customer                 str
country                  str
amount               float64
paid                    bool
order_date    datetime64[us]
ship_date     datetime64[us]
dtype: object
Enter fullscreen mode Exit fullscreen mode

The key argument is errors="coerce". As the pandas.to_numeric reference explains, it turns a value that cannot be parsed into NaN instead of raising an exception. That is what you want during cleaning: the bad value becomes a marker you can count and report, not a crash halfway through a file.

The same idea applies to dates. Passing an explicit format means 03/09/2026 becomes NaT rather than being guessed as March 9 or September 3. Whether that date was day-first or month-first is a question for the data owner, not for the parser.

What do type, range and cross-field checks look like in pandas?

Objective 1.3.1 names three kinds of validation: type, range and cross-field. Build each check as a boolean column, then keep the rows where no check fired. A table of named checks is easier to report to a stakeholder than a chain of filters.

checks = pd.DataFrame({
    "amount_not_numeric": df["amount"].isna(),            # type
    "order_date_unparsed": df["order_date"].isna(),       # type
    "amount_not_positive": df["amount"] <= 0,             # range
    "shipped_before_order": df["ship_date"] < df["order_date"],  # cross-field
})
bad = checks.any(axis=1)
print(checks[bad].set_axis(df.loc[bad, "order_id"]))
clean = df[~bad]
print(len(clean), "rows pass")
Enter fullscreen mode Exit fullscreen mode
          amount_not_numeric  order_date_unparsed  amount_not_positive  shipped_before_order
order_id
1002                   False                False                False                  True
1003                    True                False                False                 False
1004                   False                False                 True                 False
1005                   False                 True                False                 False
5 rows pass
Enter fullscreen mode Exit fullscreen mode

Two details are worth knowing for the exam. First, a comparison with NaN or NaT is always False, so a missing amount does not trip the range check; that is why the type check exists separately. Second, the duplicate of order 1004 was already gone, because drop_duplicates() ran before validation. Removing or imputing a row is a decision with consequences, and objective 1.2.4 asks you to discuss imputation against exclusion. Here exclusion is the honest choice: there is no sensible value to invent for a missing order amount.

How do Python types map to SQLite columns?

Objective 2.4.5 is about converting data types between SQL and Python. The sqlite3 module documentation lists exactly five Python types that go into SQLite without help: None, int, float, str and bytes.

Python value SQLite storage What to do
None NULL Nothing
int INTEGER Nothing
float REAL Nothing
str TEXT Nothing
bool INTEGER (0 or 1) Store as int(value)
datetime or pandas Timestamp No date type Store as ISO 8601 text

SQLite has no boolean and no date storage class. The SQLite page on datatypes and type affinity says booleans are stored as the integers 0 and 1, and dates as ISO 8601 text, a real number or an integer. Here is what happens if you skip the conversion:

con = sqlite3.connect(":memory:")
con.execute("""
CREATE TABLE orders (
    order_id   INTEGER PRIMARY KEY,
    customer   TEXT    NOT NULL,
    country    TEXT    NOT NULL,
    amount     REAL    CHECK (amount > 0),
    paid       INTEGER NOT NULL,
    order_date TEXT    NOT NULL
)""")
first = clean.iloc[0]
try:
    con.execute("INSERT INTO orders VALUES (?, ?, ?, ?, ?, ?)",
                (first["order_id"], first["customer"], first["country"],
                 first["amount"], first["paid"], first["order_date"]))
except sqlite3.ProgrammingError as e:
    print("ProgrammingError:", e)
Enter fullscreen mode Exit fullscreen mode
ProgrammingError: Error binding parameter 6: type 'Timestamp' is not supported
Enter fullscreen mode Exit fullscreen mode

So convert each row explicitly and insert the batch with executemany. The with con: block commits on success and rolls back on an exception, and the CHECK constraint in the table is a second line of defence for the range rule:

rows = [
    (int(r.order_id), r.customer, r.country, float(r.amount),
     int(r.paid), r.order_date.strftime("%Y-%m-%d"))
    for r in clean.itertuples(index=False)
]
with con:
    con.executemany("INSERT INTO orders VALUES (?, ?, ?, ?, ?, ?)", rows)
print(con.execute("SELECT COUNT(*) FROM orders").fetchone())   # (5,)

try:
    with con:
        con.execute("INSERT INTO orders VALUES (?, ?, ?, ?, ?, ?)",
                    (1010, "Eve Park", "KR", -5.0, 1, "2026-09-08"))
except sqlite3.IntegrityError as e:
    print("IntegrityError:", e)   # CHECK constraint failed: amount > 0
Enter fullscreen mode Exit fullscreen mode

Why does the exam keep asking about parameterized queries?

Two Block 2 objectives, 2.4.4 and 2.4.6, come back to the same point: never build SQL by pasting values into a string. The demonstration takes four lines.

name = "x' OR '1'='1"
unsafe = f"SELECT COUNT(*) FROM orders WHERE customer = '{name}'"
print(con.execute(unsafe).fetchone())        # (5,)  every row matched
print(con.execute("SELECT COUNT(*) FROM orders WHERE customer = ?", (name,)).fetchone())   # (0,)
print(con.execute("SELECT COUNT(*) FROM orders WHERE customer = :who",
                  {"who": "Ana Lima"}).fetchone())   # (2,)
Enter fullscreen mode Exit fullscreen mode

The f-string version closes the quote early and turns the filter into a condition that is always true. With a ? placeholder the driver sends the value separately from the SQL text, so the quote characters are just part of a name that matches nobody. The third line shows the named style, :who with a dictionary, which reads better once a query has several parameters.

How do you get query results back into pandas?

Objective 2.4.1 lists the clauses the exam expects you to combine: SELECT, FROM, JOIN, WHERE, GROUP BY, HAVING, ORDER BY and LIMIT. The query below uses all of them, with a LEFT JOIN so that an order from a country missing in the lookup table is kept and labelled rather than dropped.

con.executescript("""
CREATE TABLE countries (code TEXT PRIMARY KEY, region TEXT NOT NULL);
INSERT INTO countries VALUES ('BR', 'Americas'), ('NG', 'Africa'), ('IN', 'Asia');
""")
QUERY = """
SELECT COALESCE(c.region, 'Unmapped') AS region,
       COUNT(*)                       AS orders,
       ROUND(SUM(o.amount), 2)        AS revenue,
       MIN(o.order_date)              AS first_order
FROM orders AS o
LEFT JOIN countries AS c ON c.code = o.country
WHERE o.paid = :paid
GROUP BY region
HAVING COUNT(*) >= :min_orders
ORDER BY revenue DESC
LIMIT 5
"""
summary = pd.read_sql_query(QUERY, con, params={"paid": 1, "min_orders": 1})
print(summary)
print(summary.dtypes)
Enter fullscreen mode Exit fullscreen mode
     region  orders  revenue first_order
0    Africa       1   310.00  2026-09-04
1  Americas       2   192.75  2026-09-01
2  Unmapped       1    89.90  2026-09-05
region             str
orders           int64
revenue        float64
first_order        str
dtype: object
Enter fullscreen mode Exit fullscreen mode

The params argument of pandas.read_sql_query passes values to the driver, so the placeholder rule still holds. Look at the last dtype, though: first_order came back as text, because that is how SQLite stored it. Add parse_dates=["first_order"] to the call and the column comes back as datetime64. The round trip from Timestamp to text and back is exactly the kind of conversion objective 2.4.5 has in mind.

The Chen Wei order lands in "Unmapped" because CN is not in the lookup table. With an INNER JOIN that paid order would vanish from the revenue total without any warning, which is a classic scenario question.

Is there a short video on preparing for PCAD?

This three-minute video from the EduSum channel gives preparation tips for the PCAD exam. It is about study habits rather than code, so it pairs well with the hands-on script above.

What should you practise after this script?

Run the script, then break it on purpose. Add a row whose paid value is maybe and see what map() produces; it gives NaN, and the column is no longer boolean. Then write a validation check for it. That loop of breaking the data and then catching the break is a fast way to learn Blocks 1 and 2.

To track the rest of the syllabus objective by objective, this PCAD-31-02 syllabus breakdown with exam details lists every block and topic on one page.

For Blocks 3 to 5, write small scripts of the same kind: a bootstrap of the mean with NumPy, a linear and a logistic regression with a train and test split, and one Matplotlib chart with labelled axes, a title and a legend.

When you want to see how the scenario items are phrased, work through these PCAD sample questions with answers and note which block each one belongs to.

Frequently asked questions

1. How many questions are on the PCAD-31-02 exam, and what score do I need to pass?

The exam has 48 single-select and multiple-select items, and you have 60 minutes plus the NDA screen. You pass with a cumulative score of at least 75%.

2. How much does the PCAD exam cost?

The Python Institute lists the exam from $195, the exam with a retake from $225, the exam with a practice test from $215 and all three from $245. The practice test alone is $49.

3. Is PCAD-31-01 still available?

No. PCAD-31-01 was retired on July 14, 2025, and PCAD-31-02 has been the active version since July 15, 2025. Use the PCAD-31-02 syllabus when you study.

4. Do I need PCAP or PCED before taking PCAD?

There are no formal prerequisites. The Python Institute recommends PCAP and PCED, or equivalent knowledge, plus field-specific skills.

5. How long is the PCAD certification valid, and when can I retake a failed exam?

The certification is valid for 6 years. After a failed attempt you must wait 15 days before you can take the exam again.

Written by the team behind EduSum, a practice-test site for IT and cybersecurity certifications. We are not affiliated with the OpenEDG Python Institute.

Top comments (0)