An empty CSV cell is not zero: a small Python reporting lesson
By Olzhas Azirali, founder of AZIRAL since 2019.
A report can acquire false precision before it draws its first chart. Replace every empty CSV cell with zero, calculate a rate, and an incomplete observation suddenly looks like a measured result.
A small Python tool makes the problem easy to inspect. The public insights.py helper in AZIRAL's Instagram Agent Skill reads a local CSV and prints a JSON report. It keeps a blank metric separate from an observed zero. The distinction affects both action rates and the sample used for comparisons.
The example below is synthetic. It is a reproducible data-handling exercise, not AZIRAL account performance or a client result.
Start with a data contract
The helper requires two columns: post_id and format. Metrics such as reach, views, saves and shares are optional. A missing metric column represents an unknown observation for every row. A blank cell represents an unknown observation for that row. An explicit 0 records an observed zero.
Those meanings belong in the input contract before any calculation. Missing data could reflect an unavailable export field, an incomplete collection process or another cause. The CSV alone does not establish which explanation applies. Preserving the gap avoids silently choosing one.
For this helper, counts must be finite, nonnegative integers no larger than 9007199254740991. It rejects duplicate post IDs, repeated column names, mismatched row widths and malformed count values. A validation error stops the CLI with a nonzero exit code instead of producing a partial JSON report.
An eight-row example
Save this as synthetic.csv in a checkout of the public repository:
post_id,format,reach,views,saves,shares
r1,reel,1000,1000,0,5
r2,reel,2000,3000,10,
r3,reel,,500,4,0
r4,reel,0,0,2,1
r5,reel,500,,5,2
c1,carousel,100,200,1,0
c2,carousel,100,400,0,
c3,carousel,50,,,1
Run it with Python 3.10 or later:
python3 skills/aziral-instagram-insights/scripts/insights.py synthetic.csv
The helper uses Python's standard library. Its CSV schema assumes one account and comparable observation windows. The rows above are invented for explanation; their combinations exercise validation and calculation rules rather than describe a real account.
Five rows are reels and three are carousels. Some observations are intentionally absent. Row r4 records zero reach and zero views while preserving its other supplied counts. Keeping those raw values does not make a reach-based rate calculable.
Guard the numerator and the denominator
The calculation in the public script has a small, explicit guard:
def ratio(numerator, denominator, scale=1):
if numerator is None or denominator is None or denominator <= 0:
return None
return round(numerator / denominator * scale, 6)
This excerpt omits type annotations for readability; its calculation matches the source. For an action rate, the script passes 1000 as the scale and uses that post's reach as the denominator. These are action counts per 1000 reached accounts, not probabilities of unique-person conversion.
The results show why a single fallback value would lose information:
post saves per 1000 reach shares per 1000 reach
r1 0 5
r2 5 null
r3 null null
r4 null null
r5 10 4
Row r1 has an observed saves count of zero and positive reach, so its saves rate is zero. Row r2 has a valid saves rate but no shares observation. Row r3 has no reach observation. Row r4 has an observed denominator of zero. Both r3 and r4 therefore have unavailable action rates, while their raw metrics retain different states.
In Python, the unavailable result is None. JSON serialization represents it as null. A consumer can display an unavailable marker and retain the reason in the underlying metrics instead of presenting an invented numeric result.
Show how much data supports the summary
The report includes observed_posts counts for each metric. In the reel group, reach is observed for four of five rows. The observed zero in r4 contributes to that coverage count. Views are also observed for four rows; the blank views cell in r5 does not contribute.
Coverage is useful beside a summary because different metrics can have different denominators of available observations. A group containing five posts does not imply five observations for every field. This example also has no comments column, so the observed comments count is zero. That describes data availability, not a measured total of zero comments.
The report retains raw metrics, derived rates, group coverage and warnings together. A reader can trace an unavailable result without guessing what a blank meant earlier in the pipeline.
Keep format comparisons descriptive
The reel views sample is 0, 500, 1000 and 3000. Its median is 750. The missing observation is excluded; the observed zero is included. Row r2 therefore has a views_vs_format_median value of 4.
That number compares the row with the supplied same-format sample. The focal post is included in the median. It is not a comparison with an independently measured account baseline, and it does not explain why a post received its views.
The carousel sample has two observed views values, 200 and 400, producing a median of 300. Its group contains only three posts, so the helper adds a small-sample warning. The five-post threshold is a reporting caution chosen by the tool, not a statistical validation rule. Missing observations also generate warnings.
Publication age, paid distribution and selective exports can still affect comparability. None of those conditions can be reconstructed from these columns. A useful next experiment would define a consistent observation window and change one content variable, while recording the relevant metrics explicitly.
Make number formatting explicit
The parser uses Decimal on stripped input; it does not detect the export's numeric locale. In a direct parser check, the string 1.000 becomes the integer 1. The string 1,000 is rejected. A quoted CSV field can preserve a comma inside the cell, but that does not turn it into an accepted numeric grouping convention.
The sample deliberately uses unformatted integers. If a source uses decimal commas or grouping separators, establish that source's convention before normalizing it. Silent punctuation removal could change the value. Validation and a documented export format work together here.
A small rule with a visible consequence
Keeping unknown values distinct from zero makes the report easier to audit. The output can state which values were observed, which calculations were possible and what sample supported a comparison.
The source and usage instructions are public: https://github.com/azirali/instagram-agent-skill/tree/main/skills/aziral-instagram-insights
About the author and other AZIRAL products: https://aziral.com/en/olzhas-azirali
Prepared with AI assistance from the public source code. The eight-row example and the parser cases described here were reproduced locally with Python 3.14.7. They demonstrate implementation behavior, not audience growth or a forecast.
About the author
- IT entrepreneur and founder of AZIRAL since 2019. I develop mobile apps and practical developer tools, including Bunker, Finance Bro and Unicode Text Lab. Finance Bro builds on an open-source Flutter foundation. These notes focus on observable product behaviour, reproducible examples and clear limitations. Олжас Азирали - основатель AZIRAL и разработчик приложений. Portfolio: https://aziral.com/en/olzhas-azirali
Comments
Join the discussionThe blank-vs-observed-zero distinction is a good contract to write down. One edge worth thinking about is the r4 case (reach = 0 with saves = 2): the data is internally inconsistent, since you can't save a post that reached no one, so it may deserve a warning instead of just a null rate. Also, if the helper ever runs on pandas, read_csv's default NA parsing silently turns strings like "NA" or "null" into NaN, so passing keep_default_na=False with an explicit na_values list keeps the contract equally explicit. Do you treat a missing column differently from an all-blank column in the warnings, or are they reported the same way?
