# 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:

```csv
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:

```sh
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:

```python
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:

```text
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.
