<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[My technical Publication]]></title><description><![CDATA[My technical Publication]]></description><link>https://singh-technical-blog.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Wed, 16 Sep 2026 03:18:25 GMT</lastBuildDate><atom:link href="https://singh-technical-blog.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[How to Normalize a NumPy Array (Min-Max vs Z-Score)]]></title><description><![CDATA[Normalizing rescales your data onto a common scale — which matters a lot for machine learning models that are sensitive to the raw magnitude of features, and matters even for something as simple as co]]></description><link>https://singh-technical-blog.hashnode.dev/how-to-normalize-a-numpy-array-min-max-vs-z-score</link><guid isPermaLink="true">https://singh-technical-blog.hashnode.dev/how-to-normalize-a-numpy-array-min-max-vs-z-score</guid><category><![CDATA[Python]]></category><category><![CDATA[numpy]]></category><category><![CDATA[python programming]]></category><category><![CDATA[Technical writing ]]></category><dc:creator><![CDATA[Harpreet Singh Kapula]]></dc:creator><pubDate>Sat, 12 Sep 2026 05:29:33 GMT</pubDate><content:encoded><![CDATA[<p>Normalizing rescales your data onto a common scale — which matters a lot for machine learning models that are sensitive to the raw magnitude of features, and matters even for something as simple as comparing two columns that were never on the same units to begin with. Min-max and z-score are the two everyone reaches for first. Neither one is safe to use blindly — real datasets almost always have at least one point that breaks the assumption both of them are quietly making.</p>
<h2>Problem Statement</h2>
<p>Given a NumPy array, rescale its values onto a common range or distribution — and do it in a way that still works if one value in the data is unusually large or small.</p>
<h3>Example</h3>
<p><strong>Input:</strong></p>
<p>A small salary dataset — five ordinary salaries, and one executive salary that's wildly larger than the rest:</p>
<pre><code class="language-plaintext">[42000, 45000, 47000, 44000, 46000, 250000]
</code></pre>
<p>That last value isn't a typo. It's the realistic case — the point where naive normalization quietly stops working.</p>
<h2>Using Min-Max Normalization</h2>
<p>Min-max rescales everything into a fixed <code>[0, 1]</code> range, based on the minimum and maximum values in the data:</p>
<pre><code class="language-plaintext">import numpy as np

data = np.array([42000, 45000, 47000, 44000, 46000, 250000])

min_max = (data - data.min()) / (data.max() - data.min())
print(np.round(min_max, 3))
</code></pre>
<pre><code class="language-plaintext">[0.000 0.014 0.024 0.01  0.019 1.000]
</code></pre>
<p>Look at what happened to the five ordinary salaries: they're squeezed into a range from <code>0.000</code> to <code>0.024</code> — barely distinguishable from each other — while the one outlier claims the entire rest of the scale by itself. Min-max uses only two data points, the minimum and the maximum, to define the whole scale. One extreme value, and the other 83% of your data (five out of six points) gets compressed into a sliver near zero.</p>
<h2>Using Z-Score Standardization</h2>
<p>Z-score rescales based on the mean and standard deviation instead — how many standard deviations each value sits from the average:</p>
<pre><code class="language-plaintext">z_score = (data - data.mean()) / data.std()
print(np.round(z_score, 3))
</code></pre>
<pre><code class="language-plaintext">[-0.484 -0.445 -0.418 -0.458 -0.431  2.236]
</code></pre>
<p>This looks like it fixed the problem — the ordinary salaries aren't all clustered at exactly the same spot anymore. But look closer: they still only span a range of about <code>0.066</code> (from <code>-0.484</code> to <code>-0.418</code>), while the outlier sits all the way out at <code>2.236</code>. Here's the mechanism, made concrete: the mean of all six points is <code>79,000</code> — but the mean of just the five ordinary salaries, without the outlier, is <code>44,800</code>. One data point pulled the average up by more than 76%. Standard deviation is calculated from how far every point sits from that inflated mean, so it gets dragged upward too. Z-score is less visually dramatic about it than min-max, but it's not actually immune to the same problem.</p>
<p><strong>Note:</strong> <code>data.std()</code> defaults to population standard deviation (<code>ddof=0</code>) in NumPy, while scikit-learn's <code>StandardScaler</code> uses sample standard deviation (<code>ddof=1</code>) by default. The two will give you slightly different numbers on the same data — that's not a bug in either one, just a different default assumption about whether your array is the whole population or a sample of it.</p>
<h2>Using Robust Scaling (Median and IQR)</h2>
<p>Robust scaling swaps out both the mean and the standard deviation for statistics that don't move much when there's an outlier in the data — the median, and the interquartile range (the spread of the middle 50% of the data). The result is a scale where the median lands at <code>0</code> and the IQR itself becomes the unit of measurement — a value of <code>1</code> means "one IQR above the median," regardless of how extreme the raw numbers were:</p>
<pre><code class="language-plaintext">median = np.median(data)
q1, q3 = np.percentile(data, [25, 75])
iqr = q3 - q1

robust = (data - median) / iqr
print(np.round(robust, 3))
</code></pre>
<pre><code class="language-plaintext">[-1.4 -0.2  0.6 -0.6  0.2  81.8]
</code></pre>
<p>This is the difference that actually matters. The five ordinary salaries now span a real, usable range — <code>-1.4</code> to <code>0.6</code> — instead of being crushed into a sliver near zero or near each other. The median (<code>45,500</code>) and IQR (<code>2,500</code>) are calculated from the <em>middle</em> of the data, so one extreme value barely moves them — compare that <code>2,500</code> IQR to the mean's jump from <code>44,800</code> to <code>79,000</code> in the z-score section above. And the outlier itself, instead of quietly dominating the scale, gets a score of <code>81.8</code> — a number that immediately flags it as extreme rather than hiding it among values that look almost the same.</p>
<p><strong>Small footnote:</strong> <code>np.percentile</code>'s default interpolation method for computing Q1/Q3 can differ slightly from how scikit-learn's <code>RobustScaler</code> computes them internally. On this dataset the numbers line up cleanly, but on messier or larger datasets, don't be surprised by small discrepancies between the two if you're comparing hand-rolled NumPy against the scikit-learn equivalent.</p>
<h2>Watch Out For</h2>
<ul>
<li><p><strong>Constant or near-constant arrays.</strong> Min-max divides by <code>(max - min)</code>, which is <code>0</code> if every value is identical — that's a <code>0/0</code> division, producing <code>NaN</code> plus a <code>RuntimeWarning</code>. Z-score hits the same wall if <code>std()</code> is <code>0</code>. Check that your denominator isn't zero before scaling, or add a small epsilon.</p>
</li>
<li><p><strong>Single-element arrays.</strong> Same problem — a one-element array has a range of <code>0</code> and a standard deviation of <code>0</code>, so both min-max and z-score produce <code>NaN</code> on it.</p>
</li>
<li><p><strong>Heavily duplicated or zero-inflated data.</strong> If the middle 50% of your data is all identical values, IQR can also hit <code>0</code>, and robust scaling divides by zero the same way the other two do.</p>
</li>
<li><p><strong>Empty arrays.</strong> All three raw NumPy formulas above return an empty array cleanly with no error. Scikit-learn's scalers don't — they raise on empty input. Worth knowing if you're moving between hand-rolled code and scikit-learn in the same pipeline.</p>
</li>
<li><p>In all of these zero-denominator cases, scikit-learn's scalers handle it gracefully and output <code>0</code> instead of crashing — raw NumPy will not do that for you automatically.</p>
</li>
</ul>
<h2>Which One Should You Use?</h2>
<ul>
<li><p><strong>Data has no serious outliers, and you need values in a fixed range (like</strong> <code>[0, 1]</code> <strong>for a neural network input)?</strong> Use min-max.</p>
</li>
<li><p><strong>Data roughly follows a normal distribution, no major outliers, and the algorithm you're feeding it assumes standardized features?</strong> Use z-score.</p>
</li>
<li><p><strong>Data has outliers you can't or shouldn't remove, and you still need the <em>typical</em> values to be usefully spread out?</strong> Use robust scaling.</p>
</li>
<li><p><strong>Downstream system requires non-negative or strictly bounded input (certain GLM link functions, image pixel values)?</strong> Only min-max guarantees a bounded <code>[0, 1]</code> range — z-score and robust scaling are both unbounded and can go negative.</p>
</li>
</ul>
<blockquote>
<p><strong>Before you ship this to production:</strong></p>
<p>scikit-learn ships all three as <code>MinMaxScaler</code>, <code>StandardScaler</code>, and <code>RobustScaler</code> — reach for those directly in a real project instead of hand-rolling the formulas, since they also handle multi-column data and reuse fitted parameters consistently.</p>
<p>If you're scaling training and test data separately, fit the scaler on the training set only, then apply those same fitted parameters to the test set:</p>
<pre><code class="language-plaintext">scaler.fit_transform(X_train)   # learn parameters from training data only
scaler.transform(X_test)        # apply those same parameters to test data
</code></pre>
<p>Fitting a fresh scaler on the test set — or on the combined data — leaks information about the test set into your training process, which quietly inflates how good your model looks during evaluation.</p>
</blockquote>
<h2>Conclusion</h2>
<p>All three scalers agree on the ordinary salaries when there's no outlier in the mix — the differences here only show up because one point in the data is extreme. Min-max and z-score both let that one point dictate the scale for everything else, just through different mechanisms (range vs. mean/std). Robust scaling is the one built specifically to resist that — not because it's always the "better" choice, but because it's the only one of the three that keeps working the way you'd expect once your data stops being clean.</p>
]]></content:encoded></item><item><title><![CDATA[How to Handle Missing Values (NaN) in Pandas — 4 Methods Compared]]></title><description><![CDATA[Real datasets have gaps — a sensor that missed a reading, a form field someone skipped, a column nobody actually filled in. Pandas gives you several ways to handle NaN values, and picking the wrong on]]></description><link>https://singh-technical-blog.hashnode.dev/how-to-handle-missing-values-nan-in-pandas-4-methods-compared</link><guid isPermaLink="true">https://singh-technical-blog.hashnode.dev/how-to-handle-missing-values-nan-in-pandas-4-methods-compared</guid><category><![CDATA[Python]]></category><category><![CDATA[pandas]]></category><dc:creator><![CDATA[Harpreet Singh Kapula]]></dc:creator><pubDate>Fri, 11 Sep 2026 11:31:09 GMT</pubDate><content:encoded><![CDATA[<p>Real datasets have gaps — a sensor that missed a reading, a form field someone skipped, a column nobody actually filled in. Pandas gives you several ways to handle <code>NaN</code> values, and picking the wrong one for the situation is an easy way to either lose data you needed or corrupt data you didn't mean to touch.</p>

<h2>Before Any Method: Ask Why It's Missing</h2>

<p>The method you reach for should depend less on <em>which pandas function looks right</em> and more on <em>why the value isn't there in the first place</em>. Roughly, missing data falls into a few buckets: it might be missing for no real reason at all (random gaps — a sensor glitch), missing in a way that's predictable from other columns (a field that's only ever filled in for certain categories), or missing in a way that's actually informative — the fact that it's blank tells you something. A column that's empty in <em>every</em> row could mean "nobody bothered to fill this in" (drop it), or it could mean "this field only applies to some rows and none of them are in this sample" (a sign you need more data, not a deletion). Keep that question in mind as you go — the DataFrame below has four columns, and none of them are missing data for the same reason.</p>

<h2>Problem Statement</h2>

<p>Given a DataFrame with missing values scattered across different columns, identify them and decide how to handle each one appropriately — not just make the <code>NaN</code>s disappear.</p>

<h3>Example</h3>

<p><strong>Input:</strong></p>

<pre><code>   day  temp  humidity condition notes
0  Mon    30        65     Sunny   NaN
1  Tue   NaN        68     Sunny   NaN
2  Wed    32       NaN       NaN   NaN
3  Thu    33        70     Rainy   NaN
4  Fri   NaN        72     Rainy   NaN
5  Sat    29       NaN   Cloudy   NaN
6  Sun    28        75     Sunny   NaN</code></pre>

<p>Four columns, four different missing-data situations: <code>temp</code> and <code>humidity</code> have scattered gaps in an otherwise trending numeric sequence, <code>condition</code> is missing one categorical value, and <code>notes</code> is missing <em>every single value</em>. Those aren't the same problem, and they don't have the same fix.</p>

<h2>Step 1: Find Out What's Actually Missing</h2>

<p>Before fixing anything, see the scope of the problem:</p>

<pre><code>import pandas as pd
import numpy as np

df = pd.DataFrame({
    "day": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
    "temp": [30, np.nan, 32, 33, np.nan, 29, 28],
    "humidity": [65, 68, np.nan, 70, 72, np.nan, 75],
    "condition": ["Sunny", "Sunny", np.nan, "Rainy", "Rainy", "Cloudy", "Sunny"],
    "notes": [np.nan] * 7
})

print(df.isna().sum())</code></pre>

<pre><code>day          0
temp         2
humidity     2
condition    1
notes        7
dtype: int64</code></pre>

<p>That last line is your evidence, not just a count. <code>notes</code> missing in every row supports the "nobody filled this in" theory rather than the "only applies to some rows" theory — there's no pattern to it, it's uniformly empty. That's what tells you dropping it is reasonable, rather than a guess.</p>

<p><strong>One dtype note:</strong> <code>isna()</code> catches <code>NaN</code>, <code>None</code>, and pandas' newer <code>pd.NA</code> (used by nullable dtypes like <code>Int64</code> and <code>string</code>) all in one call — you don't need to check for each separately.</p>

<h2>Step 2: <code>dropna()</code> — Decide <em>Which</em> Drop You Mean</h2>

<p>The instinct is to just call <code>dropna()</code> and move on. Here's what that actually does on this data:</p>

<pre><code>print(df.dropna())</code></pre>

<pre><code>Empty DataFrame
Columns: [day, temp, humidity, condition, notes]
Index: []</code></pre>

<p>Every single row is gone. Not because every row has real problems — it's because <code>dropna()</code> with no arguments drops a row if <em>any</em> column has a <code>NaN</code>, and <code>notes</code> being empty in every row means every row qualifies. One useless column just cost you your entire dataset.</p>

<p>The fix isn't to avoid <code>dropna()</code> — it's to be specific about which drop you actually mean. First, drop columns that are empty across the board:</p>

<pre><code>df = df.dropna(axis=1, how="all")
print(df)</code></pre>

<pre><code>   day  temp  humidity condition
0  Mon    30        65     Sunny
1  Tue   NaN        68     Sunny
2  Wed    32       NaN       NaN
3  Thu    33        70     Rainy
4  Fri   NaN        72     Rainy
5  Sat    29       NaN   Cloudy
6  Sun    28        75     Sunny</code></pre>

<p><code>notes</code> is gone, and none of the real data was touched. From here you have finer-grained options than the all-or-nothing default. <code>subset</code> restricts the check to specific columns — drop a row only if a genuinely essential field is missing:</p>

<pre><code>print(df.dropna(subset=["temp"]))</code></pre>

<p><code>thresh</code> is less aggressive still — keep a row as long as it has <em>at least</em> that many non-null values, regardless of which columns they're in:</p>

<pre><code>print(df.dropna(thresh=3))</code></pre>

<pre><code>   day  temp  humidity condition
0  Mon    30        65     Sunny
1  Tue   NaN        68     Sunny
3  Thu    33        70     Rainy
4  Fri   NaN        72     Rainy
5  Sat    29       NaN   Cloudy
6  Sun    28        75     Sunny</code></pre>

<p>Only Wednesday drops out here — it's the one row missing <em>two</em> values (<code>humidity</code> and <code>condition</code>), so it's the only one that fails the "at least 3 out of 4 non-null" bar.</p>

<h2>Step 3: <code>fillna()</code> — for Values Where a Defensible Default Exists</h2>

<p><code>condition</code> is missing one value (Wednesday). It's categorical, so there's no "average" to fall back on — but since weather tends to persist day to day, carrying the previous day's value forward is a reasonable default:</p>

<pre><code>df["condition"] = df["condition"].ffill()
print(df["condition"])</code></pre>

<pre><code>0     Sunny
1     Sunny
2     Sunny
3     Rainy
4     Rainy
5    Cloudy
6     Sunny
Name: condition, dtype: object</code></pre>

<p>Wednesday inherits Tuesday's "Sunny" — not necessarily correct, but a reasonable guess for this kind of data, and clearly flagged as an assumption if you keep a note of what was filled. (Note: <code>fillna(method="ffill")</code> still works on older pandas but is deprecated as of pandas 2.1 — use <code>.ffill()</code> directly, same for <code>.bfill()</code> if you need to fill backward instead of forward.)</p>

<p>Two things worth knowing before you rely on this: <code>.ffill()</code> propagates the last valid value forward <em>indefinitely</em> until it hits a real one — a long run of consecutive <code>NaN</code>s, or a gap right at the start of the column with nothing before it to carry forward, will silently repeat (or fail to fill) far more than you'd expect. <code>.bfill()</code> is the mirror image — it carries the <em>next</em> valid value backward instead — worth reaching for when a gap sits at the very start of your data with nothing earlier to pull from.</p>

<p><strong>Pitfall worth knowing:</strong> calling <code>df.fillna(df.mean())</code> across an entire DataFrame — instead of one column at a time — will either error or skip non-numeric columns without telling you, because you can't average "Sunny" and "Rainy". If you want a blanket fill across only the numeric columns, be explicit about it: <code>df.select_dtypes(include="number").fillna(df.mean())</code>.</p>

<h2>Step 4: <code>interpolate()</code> — for Ordered, Trending Numeric Data</h2>

<p><code>temp</code> and <code>humidity</code> are different from <code>condition</code>: they're numeric, ordered by day, and trending — which means a value <em>between</em> the two surrounding known points is usually more accurate than picking one flat number for both gaps. By default, <code>interpolate()</code> does exactly that — linear interpolation, a straight line drawn between the known value before the gap and the known value after it:</p>

<pre><code>df["temp"] = df["temp"].interpolate()
df["humidity"] = df["humidity"].interpolate()
print(df[["day", "temp", "humidity"]])</code></pre>

<pre><code>   day  temp  humidity
0  Mon  30.0      65.0
1  Tue  31.0      68.0
2  Wed  32.0      69.0
3  Thu  33.0      70.0
4  Fri  31.0      72.0
5  Sat  29.0      73.5
6  Sun  28.0      75.0</code></pre>

<p>Tuesday's temperature lands at <code>31.0</code> — you can check this by hand: it's the straight-line average of Monday's <code>30</code> and Wednesday's <code>32</code>. Friday's humidity, <code>73.5</code>, is the average of Thursday's <code>72</code> and Saturday's <code>75</code>. Compare that to what <code>fillna(df["temp"].mean())</code> would have done: it would have dropped the same flat average (<code>30.4</code>) into both Tuesday <em>and</em> Friday, ignoring that the readings on either side of each gap tell you something different.</p>

<p>Two things to keep in mind: <code>interpolate()</code> only produces something meaningful when the values have a real numeric order — it returns nonsense on categorical, boolean, or text columns, since there's no "value between Sunny and Rainy." And if you have a <em>long</em> run of consecutive gaps rather than single missing points, unrestricted linear interpolation will draw one long flat-ish line across the whole stretch, which can be misleading — the <code>limit=</code> parameter caps how many consecutive <code>NaN</code>s it's willing to fill, so it doesn't quietly paper over a gap that's really too large to guess at.</p>

<h2>A Note on How You Assign the Fix</h2>

<p>If you see a <code>SettingWithCopyWarning</code> while doing any of this, it usually means you tried to assign into a filtered view rather than the DataFrame itself — something like <code>df[df["temp"].isna()]["temp"] = 0</code> instead of using <code>.loc[]</code>. It's unrelated to which missing-data method you picked, but it's exactly the kind of warning this topic tends to surface right after you start filtering and fixing values.</p>

<h2>Which One Should You Use?</h2>

<ul>
<li><strong>Want to know how bad the problem actually is, and why, before touching anything?</strong> Start with <code>isna().sum()</code> and ask what the pattern (or lack of one) tells you.</li>
<li><strong>An entire column is empty, or a row is missing so much it's not usable?</strong> Use <code>dropna()</code> — scoped with <code>axis</code>, <code>how</code>, <code>thresh</code>, or <code>subset</code>, not called bare.</li>
<li><strong>A categorical or non-trending value is missing, and a reasonable default exists?</strong> Use <code>fillna()</code> (or <code>.ffill()</code>/<code>.bfill()</code> directly) — with a real, deliberate value, not a blind <code>.mean()</code> across every column.</li>
<li><strong>Numeric data that's ordered and trending, with gaps in the middle?</strong> Use <code>interpolate()</code> — and cap it with <code>limit=</code> if the gaps might be long.</li>
</ul>

<h2>Conclusion</h2>

<p>The lesson isn't "avoid <code>dropna()</code>" — the article uses it, deliberately, in Step 2. The lesson is to decide <em>which</em> drop you mean: by column first, then by row, scoped with <code>subset</code> or <code>thresh</code>, instead of calling it with no arguments and hoping. Use <code>fillna()</code> when a specific, defensible default exists. Use <code>interpolate()</code> only when the data is genuinely ordered and trending — for anything else, it's not measuring what you think it's measuring.</p>]]></content:encoded></item><item><title><![CDATA[How to Merge Two Pandas DataFrames on Multiple Columns]]></title><description><![CDATA[Merging on a single column works fine until two records need more than one field to uniquely match — a student identified by student_id and term, a sale identified by region and date. This covers thre]]></description><link>https://singh-technical-blog.hashnode.dev/how-to-merge-two-pandas-dataframes-on-multiple-columns</link><guid isPermaLink="true">https://singh-technical-blog.hashnode.dev/how-to-merge-two-pandas-dataframes-on-multiple-columns</guid><category><![CDATA[Pytho]]></category><category><![CDATA[pandas]]></category><category><![CDATA[panda dataframe]]></category><dc:creator><![CDATA[Harpreet Singh Kapula]]></dc:creator><pubDate>Fri, 11 Sep 2026 06:29:52 GMT</pubDate><content:encoded><![CDATA[<p>Merging on a single column works fine until two records need more than one field to uniquely match — a student identified by <code>student_id</code> <em>and</em> <code>term</code>, a sale identified by <code>region</code> <em>and</em> <code>date</code>. This covers three ways to merge on multiple columns, and what to do when the two DataFrames don't fully agree on which rows exist.</p>

<p>The short version, before the detail: an inner merge drops any row that doesn't match on both sides — silently. A left merge keeps every row from one side and can show you which ones didn't match. An outer merge keeps everything from both sides, so you see every gap at once. The rest of this article is really just that three-way distinction, worked through in detail.</p>

<h2>Problem Statement</h2>

<p>Given two DataFrames that share more than one key column, combine them so rows match only when <em>all</em> key columns agree.</p>

<h3>Example</h3>

<p><strong>Input:</strong></p>

<pre><code># df_scores
   student_id term  score
0         101   T1     85
1         101   T2     90
2         102   T1     78
3         103   T1     92

# df_attendance
   student_id term  attendance
0         101   T1     95
1         101   T2     88
2         102   T1     80
3         104   T1     99</code></pre>

<p>Notice the two DataFrames don't fully overlap: student 103 has a score but no attendance record, and student 104 has an attendance record but no score. That mismatch is the actual problem this article is about — not just the merge syntax.</p>

<h2>Using <code>merge()</code> with <code>on=[...]</code></h2>

<p>The default case — match rows only where every key column agrees, and drop anything that doesn't have a match on both sides:</p>

<pre><code>import pandas as pd

df_scores = pd.DataFrame({
    "student_id": [101, 101, 102, 103],
    "term": ["T1", "T2", "T1", "T1"],
    "score": [85, 90, 78, 92]
})

df_attendance = pd.DataFrame({
    "student_id": [101, 101, 102, 104],
    "term": ["T1", "T2", "T1", "T1"],
    "attendance": [95, 88, 80, 99]
})

result = pd.merge(df_scores, df_attendance, on=["student_id", "term"])
print(result)</code></pre>

<pre><code>   student_id term  score  attendance
0         101   T1     85          95
1         101   T2     90          88
2         102   T1     78          80</code></pre>

<p>This is an inner merge by default — only <code>(101, T1)</code>, <code>(101, T2)</code>, and <code>(102, T1)</code> exist in <em>both</em> DataFrames, so those are the only rows that survive. Student 103's score and student 104's attendance both silently disappear. That's fine if missing data should be dropped — dangerous if you didn't realize you were dropping it.</p>

<p><strong>If the key columns have different names on each side</strong> — say the second DataFrame uses <code>id</code> and <code>term_code</code> instead of <code>student_id</code> and <code>term</code> — use <code>left_on</code>/<code>right_on</code> instead of <code>on</code>:</p>

<pre><code>pd.merge(df_scores, df_attendance_alt,
         left_on=["student_id", "term"],
         right_on=["id", "term_code"])</code></pre>

<p><strong>Two more things worth knowing before you rely on this:</strong></p>
<ul>
<li>A <code>NaN</code> in a key column never matches anything, including another <code>NaN</code> — a row with a missing key is silently treated as having no match, on either side.</li>
<li>Non-key columns that happen to share a name across both DataFrames (like <code>created_at</code>) get renamed automatically with <code>_x</code>/<code>_y</code> suffixes. Use <code>suffixes=("_scores", "_attendance")</code> if you want to control what they're called instead.</li>
</ul>

<h2>Using <code>how='left'</code> with <code>indicator=True</code></h2>

<p>When you need to keep every row from one side — and see exactly which ones didn't find a match — a left merge with the indicator flag on gives you both:</p>

<pre><code>result = pd.merge(
    df_scores, df_attendance,
    on=["student_id", "term"],
    how="left",
    indicator=True
)
print(result)</code></pre>

<pre><code>   student_id term  score  attendance     _merge
0         101   T1     85        95.0       both
1         101   T2     90        88.0       both
2         102   T1     78        80.0       both
3         103   T1     92         NaN  left_only</code></pre>

<p>Every row from <code>df_scores</code> is kept, whether or not it matched. Student 103 shows up with <code>attendance</code> as <code>NaN</code> and <code>_merge</code> flagged as <code>left_only</code> — pandas is telling you directly, row by row, which side of the merge each row actually came from. Filtering <code>result[result["_merge"] == "left_only"]</code> gives you exactly the rows an inner merge would have thrown away without telling you. (If you want to keep that column for further analysis under a clearer name, pass <code>indicator="match_status"</code> instead of <code>True</code>.)</p>

<h2>Common Pitfall: Duplicate Keys</h2>

<p>This is the gotcha that causes the most confusing production bugs in multi-column merges, and it's worth seeing once instead of just being warned about. If your key columns aren't actually unique on one side, <code>merge</code> doesn't error — it silently multiplies rows:</p>

<pre><code>df_scores_dup = pd.DataFrame({
    "student_id": [101, 101, 101],
    "term": ["T1", "T1", "T2"],
    "score": [85, 87, 90]
})

result = pd.merge(df_scores_dup, df_attendance, on=["student_id", "term"])
print(result)</code></pre>

<pre><code>   student_id term  score  attendance
0         101   T1     85          95
1         101   T1     87          95
2         101   T2     90          88</code></pre>

<p>Two different scores for the same <code>(101, T1)</code> key both matched the single attendance row — the attendance value <code>95</code> now appears twice, once per duplicate. In a small example it's obvious. On a real dataset with thousands of rows, this is exactly how a merge quietly inflates your row count and nobody notices until a downstream aggregate is mysteriously too high.</p>

<p>The fix is <code>validate</code>, which turns this from a silent bug into an explicit error:</p>

<pre><code>pd.merge(df_scores_dup, df_attendance, on=["student_id", "term"], validate="1:1")</code></pre>

<pre><code>MergeError: Merge keys are not unique in left dataset; not a one-to-one merge</code></pre>

<p>Use <code>validate="1:1"</code> when you expect exactly one match per key pair on each side, or <code>"1:m"</code>/<code>"m:1"</code> if one side is genuinely expected to repeat. It costs nothing to add and catches this class of bug before it reaches production.</p>

<h2>Using <code>how='outer'</code> to Reconcile Both Sides</h2>

<p>When neither DataFrame should lose rows — you want the full picture, gaps and all — an outer merge keeps everything from both sides:</p>

<pre><code>result = pd.merge(df_scores, df_attendance, on=["student_id", "term"], how="outer")
print(result)</code></pre>

<pre><code>   student_id term  score  attendance
0         101   T1   85.0        95.0
1         101   T2   90.0        88.0
2         102   T1   78.0        80.0
3         103   T1   92.0         NaN
4         104   T1    NaN        99.0</code></pre>

<p>Now both mismatches show up: student 103 with no attendance, student 104 with no score. Neither an inner merge nor a plain left merge would have shown you both at once.</p>

<p>Notice <code>score</code> and <code>attendance</code> became floats (<code>85.0</code> instead of <code>85</code>) — pandas can't represent a missing value in an integer column, so introducing any <code>NaN</code> forces the whole column to float. This is worth knowing before it surprises you downstream in code that expects integers.</p>

<p>From here, don't reach for <code>fillna(0)</code> without thinking about what <code>0</code> actually means for your data — a missing attendance record isn't the same fact as "attended zero classes," and filling it with <code>0</code> quietly turns "we don't know" into a false claim. If a specific default genuinely makes sense for your case, say so explicitly and comment why:</p>

<pre><code># 0 here means "no record exists yet," not "confirmed zero attendance" —
# only fill this way if your downstream logic treats them the same.
result["attendance"] = result["attendance"].fillna(0)
result["score"] = result["score"].fillna(0)

# now safe to cast back to int, since there are no NaNs left
result[["score", "attendance"]] = result[["score", "attendance"]].astype(int)</code></pre>

<p>Often the better move is to leave the <code>NaN</code>s as-is and handle the two mismatched rows deliberately — a separate lookup, a flag for manual review, whatever fits — rather than picking a placeholder number that might get misread as real data later.</p>

<p><strong>Performance note:</strong> on large DataFrames, multi-column merges are one of the more expensive pandas operations. If you're merging millions of rows, setting <code>sort=False</code> (the default since recent pandas versions) and making sure both DataFrames are already sorted on the key columns beforehand can meaningfully speed things up.</p>

<h2>Which One Should You Use?</h2>

<ul>
<li><strong>Only care about rows that exist in both DataFrames?</strong> Use the default <code>merge(on=[...])</code> — inner join.</li>
<li><strong>Need to keep every row from one side and see what didn't match?</strong> Use <code>how='left'</code> with <code>indicator=True</code>.</li>
<li><strong>Need to see and resolve mismatches from <em>both</em> sides?</strong> Use <code>how='outer'</code>, then decide how to handle the <code>NaN</code>s explicitly.</li>
<li><strong>Not sure whether your keys are actually unique?</strong> Add <code>validate="1:1"</code> before you trust any of the above.</li>
</ul>

<h2>Conclusion</h2>

<p>Don't default to the inner merge just because it's the shortest call — default to it only once you've confirmed that dropping unmatched rows is actually what you want. If you haven't confirmed that yet, run <code>how='left'</code> with <code>indicator=True</code> first and see what an inner merge would have silently thrown away. Add <code>validate</code> any time you're not certain your keys are unique — it's one keyword argument standing between you and a duplicated-row bug that's much harder to spot after the fact than before it.</p>]]></content:encoded></item><item><title><![CDATA[How to Remove Duplicate Rows in a Pandas DataFrame (3 Ways)]]></title><description><![CDATA[Duplicate rows in a pandas DataFrame usually come from messy data collection — repeated form submissions, merged datasets, or logging the same event twice. This covers three ways to remove them, from ]]></description><link>https://singh-technical-blog.hashnode.dev/how-to-remove-duplicate-rows-in-a-pandas-dataframe-3-ways</link><guid isPermaLink="true">https://singh-technical-blog.hashnode.dev/how-to-remove-duplicate-rows-in-a-pandas-dataframe-3-ways</guid><category><![CDATA[Python]]></category><category><![CDATA[python programming]]></category><dc:creator><![CDATA[Harpreet Singh Kapula]]></dc:creator><pubDate>Thu, 10 Sep 2026 13:56:07 GMT</pubDate><content:encoded><![CDATA[<p>Duplicate rows in a pandas DataFrame usually come from messy data collection — repeated form submissions, merged datasets, or logging the same event twice. This covers three ways to remove them, from the one-line fix to the version you'll need when the duplicates aren't perfectly identical.</p>

<h2>Problem Statement</h2>

<p>Given a DataFrame with duplicate rows, remove the duplicates so each unique row appears only once.</p>

<h3>Example</h3>

<p><strong>Input:</strong></p>

<pre><code>   name    city  age
0  Amit   Delhi   28
1  Neha  Mumbai   25
2  Amit   Delhi   28
3  Riya    Pune   30
4  Neha  Mumbai   25
5  Amit   Delhi   31</code></pre>

<p><strong>Output:</strong></p>

<pre><code>   name    city  age
0  Amit   Delhi   28
1  Neha  Mumbai   25
3  Riya    Pune   30
5  Amit   Delhi   31</code></pre>

<p>Rows 2 and 4 are exact duplicates of rows 0 and 1, so they're dropped. Row 5 looks similar to row 0 — same name, same city — but the age is different, so it's <em>not</em> an exact duplicate and stays.</p>

<h2>Using <code>drop_duplicates()</code></h2>

<p>The simplest and most common approach — pandas checks every column by default and drops exact-match rows:</p>

<pre><code>import pandas as pd

df = pd.DataFrame({
    "name": ["Amit", "Neha", "Amit", "Riya", "Neha", "Amit"],
    "city": ["Delhi", "Mumbai", "Delhi", "Pune", "Mumbai", "Delhi"],
    "age":  [28, 25, 28, 30, 25, 31]
})

result = df.drop_duplicates()
print(result)</code></pre>

<pre><code>   name    city  age
0  Amit   Delhi   28
1  Neha  Mumbai   25
3  Riya    Pune   30
5  Amit   Delhi   31</code></pre>

<p>By default, <code>drop_duplicates()</code> keeps the <em>first</em> occurrence of each exact duplicate and drops the rest. You can flip that with <code>keep='last'</code>, or drop every copy entirely — originals included — with <code>keep=False</code>:</p>

<pre><code>print(df.drop_duplicates(keep='last'))
print(df.drop_duplicates(keep=False))</code></pre>

<pre><code>   name    city  age
2  Amit   Delhi   28
3  Riya    Pune   30
4  Neha  Mumbai   25
5  Amit   Delhi   31

   name  city  age
3  Riya  Pune   30
5  Amit Delhi   31</code></pre>

<p>You can also limit the check to specific columns with <code>subset</code>, which treats rows as duplicates if they match on just those columns — even if other columns differ:</p>

<pre><code>print(df.drop_duplicates(subset=["name", "city"]))</code></pre>

<pre><code>   name    city  age
0  Amit   Delhi   28
1  Neha  Mumbai   25
3  Riya    Pune   30</code></pre>

<p>This is a different result from the default call above. Checking only <code>name</code> and <code>city</code>, row 5 now counts as a duplicate of row 0 — even though their <code>age</code> differs — so it's dropped this time. Whether that's what you want depends on whether <code>age</code> should matter to the comparison.</p>

<p><strong>Pro tip:</strong> dropping duplicates leaves gaps in the index (<code>0, 1, 3, 5</code> instead of <code>0, 1, 2, 3</code>). If that trips up code downstream that assumes a clean sequential index, chain on <code>.reset_index(drop=True)</code>.</p>

<h2>Using <code>duplicated()</code> as a Boolean Mask</h2>

<p>You can get the same effect as <code>drop_duplicates()</code> manually with <code>duplicated()</code>, which is worth knowing when you want the mask itself — to log or inspect which rows are duplicates before removing anything:</p>

<pre><code>mask = df.duplicated()
print(mask)

result = df[~mask]
print(result)</code></pre>

<pre><code>0    False
1    False
2     True
3    False
4     True
5    False
dtype: bool

   name    city  age
0  Amit   Delhi   28
1  Neha  Mumbai   25
3  Riya    Pune   30
5  Amit   Delhi   31</code></pre>

<p><code>duplicated()</code> returns <code>True</code> for every row that's an exact repeat of an earlier one. Row 5 comes back <code>False</code> — same as with the default <code>drop_duplicates()</code> call — because it isn't an <em>exact</em> duplicate of row 0, just a partial match on name and city. Flipping the mask with <code>~</code> and indexing with it gives the same result as <code>drop_duplicates()</code>, but now you've got the mask to work with directly.</p>

<h2>Using <code>groupby()</code> to Deduplicate and Aggregate</h2>

<p>When "duplicate" rows differ in some columns and you need a deterministic rule for combining them, rather than arbitrarily picking one, <code>groupby()</code> is the right tool:</p>

<pre><code>result = df.groupby(["name", "city"], as_index=False).agg({"age": "max"})
print(result)</code></pre>

<pre><code>   name    city  age
0  Amit   Delhi   31
1  Neha  Mumbai   25
2  Riya    Pune   30</code></pre>

<p>This is the case where the three methods genuinely disagree. <code>drop_duplicates()</code> kept Amit's age as <code>28</code> — the first occurrence, arbitrarily. <code>groupby().agg({"age": "max"})</code> looks at <em>all</em> of Amit's rows (<code>28</code> and <code>31</code>) and explicitly keeps the larger value. If <code>28</code> were a stale reading and <code>31</code> the correct one, the first two methods would have silently kept the wrong number — <code>groupby</code> is the one that actually resolves the conflict instead of ignoring it.</p>

<p>One thing to note: <code>groupby()</code> sorts its output by the grouping keys by default (alphabetically here: Amit, Neha, Riya), while <code>drop_duplicates()</code> preserves the original row order. If you run both on a differently-ordered DataFrame, don't be surprised that the row order doesn't match — that's expected, not a bug.</p>

<h2>Which One Should You Use?</h2>

<ul>
<li><strong>Duplicates are exact matches, and you just want them gone?</strong> Use <code>drop_duplicates()</code>.</li>
<li><strong>You need to know exactly which rows got dropped before committing to it?</strong> Use <code>duplicated()</code> to get the mask first.</li>
<li><strong>"Duplicate" rows actually differ in some columns, and you need a rule for combining them?</strong> Use <code>groupby().agg()</code>.</li>
</ul>

<h2>Conclusion</h2>

<p>Start with <code>drop_duplicates()</code> — it covers the common case in one line. Switch to <code>duplicated()</code> when you need visibility into what's being removed before you commit to it. Switch to <code>groupby().agg()</code> the moment "duplicate" stops meaning "identical" and starts meaning "needs a rule" — that's the case the other two methods can't actually handle correctly.</p>]]></content:encoded></item><item><title><![CDATA[OpenAI Says an AI Solved a $1 Million Math Problem. Here's What Actually Happened.]]></title><description><![CDATA[On September 8, 2026, OpenAI said something wild.

An internal, unreleased AI system had cracked part of the Navier-Stokes existence and smoothness problem. One of the seven Clay Mathematics Institute]]></description><link>https://singh-technical-blog.hashnode.dev/openai-says-an-ai-solved-a-1-million-math-problem-here-s-what-actually-happened</link><guid isPermaLink="true">https://singh-technical-blog.hashnode.dev/openai-says-an-ai-solved-a-1-million-math-problem-here-s-what-actually-happened</guid><category><![CDATA[Technical writing ]]></category><dc:creator><![CDATA[Harpreet Singh Kapula]]></dc:creator><pubDate>Thu, 10 Sep 2026 08:20:52 GMT</pubDate><content:encoded><![CDATA[<p>On September 8, 2026, OpenAI said something wild.</p>

<p>An internal, unreleased AI system had cracked part of the Navier-Stokes existence and smoothness problem. One of the seven Clay Mathematics Institute Millennium Prize Problems. A $1 million reward. Untouched for most of a century.</p>

<p>The internet did what the internet does. It split into two arguments.</p>

<p>One argument was about credit. NYU mathematician Tristan Buckmaster and his collaborator Levent Alpöge — a mathematician who works at Anthropic, but who was pursuing this work independently of his employer — said they'd been developing closely related unpublished work. Buckmaster publicly questioned whether OpenAI's system got there on its own.</p>

<p>The other argument barely happened at all. It's the actual mathematics. What the problem asks. What mechanism the proof proposes. How a swarm of AI agents allegedly built it. What "verified in Lean" does and doesn't mean.</p>

<p>That's the argument this piece is having.</p>

<p>The credit dispute is real. It's worth knowing about. It's covered in depth elsewhere. We're not relitigating it here.</p>

<p><strong>Quick timeline, so the hour-counts below actually mean something:</strong></p>

<ul>
<li><strong>Aug 28</strong> — OpenAI starts training the new internal model behind this result</li>
<li><strong>Sept 1</strong> — After hearing rumors that two Millennium Prize problems had fallen, OpenAI points the model at all of them</li>
<li><strong>Sept 5</strong> — Agents reach the Navier-Stokes resolution, ~88 hours after launch</li>
<li><strong>Sept 6</strong> — Lean formalization and verification wraps, ~17 hours later</li>
<li><strong>Sept 8</strong> — Public announcement</li>
</ul>

<p>One more thing before we start. OpenAI hasn't released its internal model, its agent orchestration system, or the full 166-page proof as executable code. The Python snippets below are mine. Simplified. Built to make one idea concrete at a time. None of them reproduce or verify OpenAI's actual proof — the real formal proof, in Lean 4, is public on OpenAI's GitHub. Each snippet is labeled with exactly what job it's doing: building intuition, making a quantitative point, or illustrating structure. Nothing more.</p>

<h2>What the Problem Actually Asks</h2>

<p>Navier-Stokes describes how fluids move. Air over a wing. Blood through a vessel. Water in a pipe.</p>

<p>The question underneath the Millennium Prize is deceptively simple: start with smooth, finite-energy, divergence-free initial data in three dimensions. Does the fluid stay smooth forever? Or can it blow up — velocity going to infinity at some point — in finite time?</p>

<p>The Clay Institute actually allows four ways to answer this. Labeled (A) through (D).</p>

<p>(A) and (B) would prove global smoothness always holds — for whole space, and for the periodic case.</p>

<p>(C) and (D) would disprove it. You'd need a smooth, divergence-free initial condition and a smooth external force where the solution blows up in finite time. Again, whole space and periodic torus.</p>

<p>OpenAI claims (C) and (D). A disproof. Not "fluids always stay smooth" — the opposite.</p>

<h2>The Hard Constraint Nobody Mentions</h2>

<p>Here's why this resisted proof for decades.</p>

<p>You can trivially blow up a fluid by applying an infinitely large or singular external force. That doesn't count. Nobody's impressed by that.</p>

<p>The real ask: velocity diverges to infinity in finite time, while the applied force stays smooth and finite, while total kinetic energy stays bounded the entire time, while viscosity — the fluid's internal friction, the thing that normally damps everything out — stays strictly positive throughout.</p>

<p>Infinite velocity. Bounded energy. Smooth forcing. All at once. For the entire evolving 3D field.</p>

<p>That's the crux of the whole problem.</p>

<h3>A number that makes the constraint real</h3>

<p>A self-similar blow-up typically has velocity scaling like <code>v(t) ~ (T* − t)<sup>−α</sup></code> as time <code>t</code> approaches the blow-up time <code>T*</code>. On its own, that's easy — velocity clearly diverges. The hard part is total kinetic energy, which depends on both velocity and the spatial scale of the motion, staying bounded the whole time. The length scale has to shrink at exactly the right rate to cancel the growing velocity in the energy calculation.</p>

<p>This snippet is a numerical illustration of that balance. Not a proof. Just a demonstration that the balancing act is at least numerically achievable in a toy setting.</p>

<pre><code class="language-python">import numpy as np

T_star = 1.0   # blow-up time (illustrative)
alpha = 0.5    # self-similar velocity scaling exponent (illustrative, not OpenAI's real exponent)
times = np.array([0, 0.5, 0.8, 0.9, 0.95, 0.99, 0.999, 0.9999])

def velocity_scale(t, T_star, alpha):
    return (T_star - t) ** (-alpha)

# Choose the length-scale exponent so velocity^2 * length_scale^3 (toy "energy") stays constant.
# This is the balancing act a real blow-up construction must achieve rigorously, everywhere, not just at sample points.
length_power = 2 * alpha / 3

for t in times:
    v = velocity_scale(t, T_star, alpha)
    length_scale = (T_star - t) ** length_power
    toy_energy = v**2 * length_scale**3
    print(f"t={t:.4f}  velocity={v:9.3f}  length_scale={length_scale:8.5f}  toy_energy={toy_energy:6.4f}")
</code></pre>

<pre><code>t=0.0000  velocity=    1.000  length_scale=1.00000  toy_energy=1.0000
t=0.5000  velocity=    1.414  length_scale=0.79370  toy_energy=1.0000
t=0.8000  velocity=    2.236  length_scale=0.58480  toy_energy=1.0000
t=0.9000  velocity=    3.162  length_scale=0.46416  toy_energy=1.0000
t=0.9500  velocity=    4.472  length_scale=0.36840  toy_energy=1.0000
t=0.9900  velocity=   10.000  length_scale=0.21544  toy_energy=1.0000
t=0.9990  velocity=   31.623  length_scale=0.10000  toy_energy=1.0000
t=0.9999  velocity=  100.000  length_scale=0.04642  toy_energy=1.0000
</code></pre>

<p>Look at that last column. Velocity climbs from 1 to 100. Toy energy sits flat at 1.0 the entire time.</p>

<p>That's the single most important number in this article. You're not just proving something blows up. You're proving it blows up <em>cleanly</em> — every quantity that's supposed to stay finite actually stays finite, right up to the last instant. Doing that rigorously, across an entire evolving 3D vector field, continuously, not at eight sample points — that's the multi-decade difficulty. The blow-up isn't the hard part. Controlling it this precisely is.</p>

<h2>The Proposed Mechanism: A Vortex That Feeds Itself</h2>

<p>Picture a vortex. A rotating column of fluid. Now picture it spiraling inward while stretching along its own axis — like twisting and pulling a piece of rope at the same time.</p>

<p>As the core shrinks, rotational velocity near the core increases. Here's the part that matters: this isn't a one-time effect. It's a loop. Faster rotation stretches the vortex further. That shrinks the core further. Which speeds up rotation further. It compounds until velocity diverges at a finite time <code>T*</code> — even with viscosity still acting on the fluid the whole time.</p>

<p>This style of construction — self-similar blow-up, where the solution looks the same at every scale as it approaches the singularity, just rescaled — is the same class of technique Buckmaster's prior published work explored for related equations. Reportedly the same class he and Alpöge were pursuing in their unpublished work, too.</p>

<h3>Watching the loop run</h3>

<p>A before/after snapshot could be mistaken for ordinary angular-momentum conservation, which needs no feedback at all. So this version steps through it: current rotation drives further core-thinning, and the thinner core drives faster rotation on the next step.</p>

<pre><code class="language-python">def simulate_stretching_feedback(steps, core_radius_init, gamma_init, stretch_rate):
    """
    Toy iterative model of vortex self-stretching feedback:
    - angular_velocity depends on circulation / core_radius^2
    - thinning (how fast the core shrinks) is driven by the CURRENT angular_velocity
    - the loop: faster rotation -&gt; faster thinning -&gt; even faster rotation next step
    This is the feedback structure, not just a single shrink-then-measure snapshot.
    """
    core_radius = core_radius_init
    gamma = gamma_init
    history = []
    for step in range(steps):
        if core_radius &lt; 1e-4:
            break
        angular_velocity = gamma / core_radius**2
        peak_velocity = gamma / core_radius
        history.append((step, core_radius, angular_velocity, peak_velocity))
        thinning = stretch_rate * angular_velocity * core_radius
        core_radius = max(core_radius - thinning, 0.0)
    return history

history = simulate_stretching_feedback(steps=10, core_radius_init=0.5, gamma_init=1.0, stretch_rate=0.05)
for step, r, omega, v in history:
    print(f"step={step:2d}  core_radius={r:8.5f}  angular_velocity={omega:10.3f}  peak_velocity={v:10.3f}")
</code></pre>

<pre><code>step= 0  core_radius= 0.50000  angular_velocity=     4.000  peak_velocity=     2.000
step= 1  core_radius= 0.40000  angular_velocity=     6.250  peak_velocity=     2.500
step= 2  core_radius= 0.27500  angular_velocity=    13.223  peak_velocity=     3.636
step= 3  core_radius= 0.09318  angular_velocity=   115.170  peak_velocity=    10.732
</code></pre>

<p>Watch step 0 to step 3. Core radius drops from 0.5 to 0.093. Angular velocity jumps from 4 to 115 — almost 29x, in three steps.</p>

<p>That's the feedback signature. It's not that a smaller core happens to spin faster — that's true of any static angular-momentum quantity, feedback or not. It's that rotation <em>actively drives</em> further thinning, which drives faster rotation, and the two amplify each other until the toy model's own step size can't resolve it anymore. A real proof has to control that compounding continuously, for all time up to <code>T*</code> — which is exactly why it needs the energy bound from the section above to hold the whole way through.</p>

<h2>How the Multi-Agent Pipeline Reportedly Worked</h2>

<p>This wasn't one long, continuous search. OpenAI's own description breaks it into stages.</p>

<p><strong>Decompose first.</strong> Instead of pointing every agent at the full 3D viscous problem immediately, OpenAI split it into variants of increasing difficulty.</p>

<p><strong>Warm up on something easier.</strong> Nearly 100 agents went at the Euler equations blow-up problem — Euler is Navier-Stokes without viscosity, mathematically simpler. Solved in roughly 50 hours.</p>

<p><strong>Transfer the insight.</strong> Techniques and partial results from that warm-up got fed back in as scaffolding for the harder, viscous case.</p>

<p><strong>Cross-pollinate with Codex.</strong> OpenAI used its Codex coding agent as a consolidation layer — merging the strongest ideas surfacing across different agent groups, instead of leaving thousands of them working in total isolation.</p>

<p><strong>Run it at scale.</strong> Across the full campaign, roughly 10,000 agents coordinated over 88 hours, using an internal next-generation model reported to be more capable than the publicly available GPT-6 Astra. That's what built the full analytical proof.</p>

<p><strong>Verify it independently.</strong> Once the proof existed in natural language and mathematics, it still had to go into Lean 4 and get mechanically checked. A separate 17-hour process, using GPT-6 Astra.</p>

<p>Now — a caveat worth stating plainly. "10,000 agents" almost certainly doesn't mean 10,000 independent reasoning engines, each chasing a distinct proof strategy from scratch. At this scale, a swarm typically means a large share of sub-tasks, validation passes, retries on dead branches, and specialized roles — drafting, checking, formalizing — rather than 10,000 parallel mathematicians. OpenAI hasn't published the breakdown. Treat the headline number as an upper bound on parallelism, not a literal head count.</p>

<p>And "brute force" here doesn't mean exhaustive search. Agents weren't randomly trying algebraic manipulations until something stuck — that's computationally hopeless at this depth. It was massive parallel exploration on human-decomposed sub-problems, with an automated step merging progress across groups.</p>

<h2>Where the Credit Dispute Touches the Method</h2>

<p>Separate question from how the pipeline worked: what fed into it.</p>

<p>Buckmaster has said he doesn't know whether OpenAI's system was influenced by his and Alpöge's unpublished work — possibly through their use of OpenAI's Codex tool in the weeks before the announcement. OpenAI has denied using their proof or prompts to direct its agents. Neither claim is independently settled as of this week. It's a separate question from whether the pipeline above is technically sound. We're not speculating further on it here.</p>

<h2>What Lean Verification Actually Checks</h2>

<p>"Verified in Lean" is a narrower claim than "mathematicians confirmed this solves the Millennium Prize problem." The gap matters.</p>

<p>Lean 4 is a formal proof assistant built on dependent type theory. It doesn't read a natural-language argument and vouch that it "sounds right." Every definition, lemma, and inference gets re-expressed in Lean's own formal language. A small, trusted kernel mechanically checks that each step's premises are fully discharged against everything proven before it. Zero tolerance for gaps. If the formal statement faithfully represents what it claims to, a successful Lean check is strong evidence the internal logic is airtight.</p>

<p>What it does <em>not</em> check: whether the formal statement is a faithful translation of the Clay Institute's exact problem. That still needs expert human mathematicians. That review wasn't complete as of this week.</p>

<h2>What's Still Open</h2>

<p>Three things, stated plainly.</p>

<p><strong>Independent peer review is incomplete.</strong> Lean confirms internal logical consistency. It doesn't by itself confirm the formal theorem is an accurate encoding of the Millennium Prize problem's exact conditions.</p>

<p><strong>The provenance question is unresolved.</strong> Whether OpenAI's agents were influenced, directly or indirectly, by Buckmaster and Alpöge's unpublished work is disputed by both sides. Not independently settled.</p>

<p><strong>The pipeline isn't independently reproducible yet.</strong> The internal model and agent framework are unreleased. Outside researchers can't verify the "10,000 agents, 88 hours" claim firsthand.</p>

<h2>Bottom Line</h2>

<p>Here's the one technical claim worth holding onto: a specific self-stretching vortex construction is claimed to produce a mathematically valid finite-time blow-up for Navier-Stokes, under the simultaneous constraints of bounded energy and smooth, finite forcing. A balance that resisted rigorous construction for the better part of a century.</p>

<p>Whether a decomposed swarm of AI agents assembled that construction through genuine mathematical insight, or converged on a path other researchers had already mapped — that's a separate, unresolved question.</p>

<p>Only the broader mathematical community's ongoing review will settle it.</p>]]></content:encoded></item><item><title><![CDATA[What an Index Actually Is (Once You Stop Calling It a Book Index)]]></title><description><![CDATA[You've heard the analogy.

An index is like the index at the back of a book. Or a phone book, sorted by last name. Look up "Smith," flip to the right page, done. No index? You'd read the whole book co]]></description><link>https://singh-technical-blog.hashnode.dev/what-an-index-actually-is-once-you-stop-calling-it-a-book-index</link><guid isPermaLink="true">https://singh-technical-blog.hashnode.dev/what-an-index-actually-is-once-you-stop-calling-it-a-book-index</guid><category><![CDATA[SQL]]></category><category><![CDATA[Technical writing ]]></category><dc:creator><![CDATA[Harpreet Singh Kapula]]></dc:creator><pubDate>Mon, 07 Sep 2026 13:19:12 GMT</pubDate><content:encoded><![CDATA[<p>You've heard the analogy.</p>

<p>An index is like the index at the back of a book. Or a phone book, sorted by last name. Look up "Smith," flip to the right page, done. No index? You'd read the whole book cover to cover, hunting for one name.</p>

<p>Simple. Clean. Easy to remember.</p>

<p>It's also true.</p>

<p>Which is exactly why it's easy to stop there.</p>

<p>Because that analogy is where every single tutorial on database indexes stops. Not because it's lazy — it genuinely earns its keep for a first exposure. But it hands you a comfortable picture and walks away, right before the part that actually matters. It tells you <em>that</em> a sorted lookup beats scanning everything. It never tells you <em>what that sorted thing actually is</em>, how it gets built, or what it costs you every time you write a row.</p>

<p>So here's the deal. Strip the book metaphor away completely. Let's look at what an index actually is — a real structure, sitting in memory, built a specific way, with real tradeoffs you're paying whether you know it or not.</p>

<p>And here's the part that makes this worth your next five minutes: that same structure, wearing a different name, is quietly running every AI search feature you've used this year. Same idea. Different disguise. Keep reading and you'll never look at "add an index" the same way again.</p>

<h2>The explanation you already have</h2>

<p>Here's the query everyone writes at some point:</p>

<pre><code>SELECT * FROM orders WHERE customer_id = 48213;</code></pre>

<p>No index on <code>customer_id</code>? Your database checks every single row. One at a time. Was it 48213? No. Next. Was it 48213? No. Next. On a table with a thousand rows, you won't even notice. On a table with ten million rows, you'll notice. You'll notice for eight full seconds while your users stare at a spinner.</p>

<p>Add an index:</p>

<pre><code>CREATE INDEX idx_customer_id ON orders(customer_id);</code></pre>

<p>Same query. Same data. Now it comes back in milliseconds.</p>

<p>That's the part everyone tells you. Fast lookup, sorted structure, book index, phone book, pick your metaphor. True. Also the ceiling, not the floor. Below that ceiling is where this gets interesting.</p>

<h2>What's actually happening in memory</h2>

<p>An index isn't a setting you flip. It's not a hint you give the database. It's an entirely separate structure — usually something called a B-tree — that your database builds and maintains, sitting right alongside your actual table: living on disk, and fighting for space in your database's memory cache right along with everything else you're querying.</p>

<p>Picture it like this. A root node at the top. Branch nodes underneath, splitting the data into ranges. Leaf nodes at the bottom, holding the actual sorted values and a pointer back to where the real row lives.</p>

<pre><code>                [ 50 ]
              /        \
        [ 20 ]          [ 80 ]
        /    \           /    \
   [1-19]  [21-49]  [51-79]  [81-∞]</code></pre>

<p>Looking for customer 48213? You don't scan a million rows. You start at the root, ask "bigger or smaller than 50?", drop down one level, ask again, drop down again — a handful of comparisons instead of a million. That's the entire trick. That's why <code>O(n)</code> — check every row — becomes <code>O(log n)</code> — check a few, cut the problem in half each time. On a million-row table, that's the difference between roughly a million comparisons and about twenty.</p>

<p>Twenty. Not twenty thousand. Twenty.</p>

<p>Here's what <code>EXPLAIN ANALYZE</code> shows you before the index exists:</p>

<pre><code>Seq Scan on orders  (cost=0.00..21847.00 rows=1 width=96) (actual time=0.021..187.442 rows=1 loops=1)
  Filter: (customer_id = 48213)
  Rows Removed by Filter: 999999
Execution Time: 187.501 ms</code></pre>

<p>Read that middle line again. <code>Rows Removed by Filter: 999999</code>. Your database checked 999,999 rows it didn't need, just to find the one it did.</p>

<p>Now with the index:</p>

<pre><code>Index Scan using idx_customer_id on orders  (cost=0.43..8.45 rows=1 width=96) (actual time=0.018..0.019 rows=1 loops=1)
  Index Cond: (customer_id = 48213)
Execution Time: 0.031 ms</code></pre>

<p>One row checked. Not a million. One.</p>

<p>But — and this is the part the book-index analogy conveniently skips — that speed isn't free. Every time you <code>INSERT</code>, <code>UPDATE</code>, or <code>DELETE</code> a row, the database doesn't just touch your table. It has to go find the right spot in that B-tree and update it too — and a real B-tree stays <em>balanced</em>, which means an insert can trigger a node split, cascading a rebalance up the tree just to keep every lookup path the same length. More indexes, more of that work, every single write. You're not getting fast reads for nothing. You're trading write speed for read speed, on purpose, every single time you type <code>CREATE INDEX</code>.</p>

<p>That's not a footnote. That's the whole deal — and it's also the whole reason this bites people. It runs fine on your laptop. A few thousand rows, everything's instant, you ship it, you feel good. Then production happens. The table's grown to two million rows, and that same query that felt instant now takes eight seconds. Nobody added the index. The sequential scan that was invisible at a thousand rows becomes very visible at two million. Same code. Different amount of data to plow through.</p>

<h2>Why this constrains real systems</h2>

<p>Here's the part that surprises people: more indexes isn't always the fix. Production DBA teams spend real time <em>removing</em> indexes, not just adding them. A table with ten indexes pays that rebalancing cost ten times on every single write — that's index bloat, and on a hot table (constant inserts, constant updates) it can slow writes down enough that the "fix" becomes the new bottleneck. An index nobody's queries actually use is pure cost, no benefit, sitting there quietly taxing every write forever. Knowing what an index actually <em>is</em> — not a free setting, but a structure with its own upkeep — is what tells you when to stop adding them.</p>

<p>Now here's where it gets genuinely interesting, and where this stops being "just a SQL tip."</p>

<p>Every AI product you've used this year that does semantic search — ask a question, get back the <em>relevant</em> documents, not just ones with matching keywords — is running on vector databases. And vector databases have their own version of this exact same problem, wearing a different costume.</p>

<p>Instead of comparing <code>customer_id = 48213</code>, they're comparing the "closeness" of two lists of numbers — embeddings — across potentially billions of them. Brute-force compare a query against every single embedding, and you're back to the sequential scan, just with worse math. So instead, most of them use something called HNSW — Hierarchical Navigable Small World graphs — a structure built ahead of time so most of the data can be skipped instead of touched.</p>

<p>Worth being precise here: a B-tree is exact and deterministic, and it's happy living mostly on disk. HNSW is probabilistic — it gives you the <em>approximate</em> nearest matches, not guaranteed-exact ones — and it wants to live in RAM, not disk, to hit the speeds vector search is known for. Different mechanics entirely. But strip away the implementation and it's the same shape of tradeoff: pay upfront in memory and build time, so that lookups cost you almost nothing later. And it has the same catch, too — HNSW's speed depends on that graph structure fitting in memory, the same way your hash join from the last article depended on its hash table fitting in RAM. Miss that budget, and the fast thing quietly stops being fast, no error message, no warning, just a system doing more work than you think it's doing.</p>

<h2>The code, reframed</h2>

<p>Same line you'd write without thinking twice about it:</p>

<pre><code>CREATE INDEX idx_customer_id ON orders(customer_id);
-- Builds a B-tree: a sorted structure of customer_id values,
--   each one holding a pointer back to its actual row.
-- Turns a linear O(n) row-by-row scan into an O(log n) tree walk —
--   a handful of comparisons instead of a million.
-- Costs you on every write: INSERT, UPDATE, and DELETE on this table
--   now also update this structure, every single time.
-- The exact same trade every vector database makes when it builds
--   an HNSW graph for semantic search — memory and build time, spent
--   once, so every future lookup gets to be nearly instant.</code></pre>

<p>Run <code>EXPLAIN ANALYZE</code> on it yourself. Watch <code>Seq Scan</code> turn into <code>Index Scan</code>. Watch <code>Rows Removed by Filter: 999999</code> disappear entirely. That's not a metaphor anymore. That's your database showing you, in plain text, exactly which of these two things it just did.</p>

<h2>Where this leaves you</h2>

<p>"Just add an index" was never magic — it's a structure, and a tradeoff, the same bet your database and every AI search feature you use make every time speed matters more than the cost of getting there.</p>

<p>Next time someone tells you to "just add an index" — you'll know exactly what you're building, and exactly what you're paying for it.</p>]]></content:encoded></item><item><title><![CDATA[Your JOIN Isn't Slow Because of Syntax. It's Slow Because of Memory.]]></title><description><![CDATA[You've probably seen the meme — two people arguing over JOIN ... ON vs JOIN ... USING like one of them is obviously wrong. Here's the version that started this one:


  
    ❌ "The wrong one"
    SELE]]></description><link>https://singh-technical-blog.hashnode.dev/your-join-isn-t-slow-because-of-syntax-it-s-slow-because-of-memory</link><guid isPermaLink="true">https://singh-technical-blog.hashnode.dev/your-join-isn-t-slow-because-of-syntax-it-s-slow-because-of-memory</guid><category><![CDATA[SQL]]></category><category><![CDATA[Technical writing ]]></category><dc:creator><![CDATA[Harpreet Singh Kapula]]></dc:creator><pubDate>Mon, 07 Sep 2026 09:34:52 GMT</pubDate><content:encoded><![CDATA[<p>You've probably seen the meme — two people arguing over <code>JOIN ... ON</code> vs <code>JOIN ... USING</code> like one of them is obviously wrong. Here's the version that started this one:</p>

<div style="display:flex;gap:16px;margin:24px 0;flex-wrap:wrap">
  <div style="flex:1;min-width:260px;border:2px solid #e5484d;border-radius:8px;padding:16px;background:#2a1414">
    <div style="color:#ff8080;font-weight:bold;margin-bottom:8px">❌ "The wrong one"</div>
    <pre style="margin:0;background:transparent;padding:0"><code>SELECT
    e.employee_id,
    e.employee_id,
    e.department
FROM employees e
JOIN sales s
  ON e.employee_id = s.employee_id;</code></pre>
  </div>
  <div style="flex:1;min-width:260px;border:2px solid #30a46c;border-radius:8px;padding:16px;background:#0f2a1c">
    <div style="color:#7ee2a8;font-weight:bold;margin-bottom:8px">✅ "The right one"</div>
    <pre style="margin:0;background:transparent;padding:0"><code>SELECT
    e.employee_id,
    e.employee_name,
    e.department
FROM employees e
JOIN sales s
  USING (employee_id);</code></pre>
  </div>
</div>

<p>It's a fun meme. It also gets the performance story wrong. <code>ON</code> and <code>USING</code> produce the exact same execution plan — same cost, same performance, same everything. <code>USING</code> is shorthand for when both columns share a name, nothing more. The meme isn't a perf tip; it's a style preference dressed up as one.</p>

<p>The real performance story isn't in what you typed. It's in what the database does <em>after</em> you hit enter. A <code>JOIN</code> isn't a keyword — it's an instruction to go find matching rows in memory. <em>How</em> it finds them is the entire reason your query is fast or slow, syntax aside. That's what this article is actually about.</p>

<h2>The explanation you've already heard</h2>

<p>Here's the query from the meme, cleaned up:</p>

<pre><code>SELECT
    e.employee_id,
    e.employee_name,
    e.department,
    s.net_sales
FROM employees e
JOIN sales s USING (employee_id);</code></pre>

<p>And here's the standard explanation you've gotten for it: <em>"A JOIN combines rows from two tables where a condition matches."</em></p>

<p>That's true. It's also not wrong. But notice what it describes — the <em>output</em>, not the <em>work</em>. It's the SQL equivalent of describing a <code>for</code> loop as "it repeats" without saying what's being repeated, how many times, or at what cost. That description is a ceiling, not a floor. Everything interesting about JOIN performance lives below it.</p>

<p>So let's go below it.</p>

<h2>What's actually happening in memory</h2>

<p>When your database sees <code>JOIN</code>, it doesn't run "the JOIN algorithm" — it picks between several, based on table size, indexes, available memory, and one thing that trips people up: table statistics. The optimizer isn't looking at your actual data when it chooses a strategy — it's looking at a cached estimate of that data's shape. If those statistics are stale — say, your database still thinks a table has 10 rows when it actually has 2 million — the optimizer will confidently pick nested loop for a table that's grown far past the point where that's a good idea, regardless of how much RAM you have sitting idle. Same SQL, three completely different things could be happening under the hood, and the optimizer's beliefs about your data are part of why.</p>

<h3>Nested loop join — two loops, one memory scan inside another</h3>

<p>This is the most literal interpretation of "match rows where a condition holds," and it's exactly what it sounds like:</p>

<pre><code>-- What you write:
SELECT e.employee_name, s.net_sales
FROM employees e
JOIN sales s USING (employee_id);

-- What the engine effectively does:
FOR each row e IN employees:        -- outer loop, scans employees in memory
    FOR each row s IN sales:        -- inner loop, RE-scans sales for EVERY outer row
        IF e.employee_id == s.employee_id:
            emit(e, s)</code></pre>

<p>For every single row in <code>employees</code>, the engine walks the <em>entire</em> <code>sales</code> table looking for a match. That's <code>O(n × m)</code> — multiply the row counts of both tables together, and that's roughly how much work you're asking for. (If the inner table has an index on the join column, this drops to <code>O(n × log m)</code> — the inner loop gets to look the row up instead of scanning for it — but it's still repeating that lookup once per outer row, which is the part that matters here.)</p>

<p>At small scale, this is completely fine — trivial, even. It's usually what your database reaches for automatically when one of the tables is tiny, because the overhead of anything smarter isn't worth it. The problem is what happens when nobody's watching the row counts grow. A join that ran in milliseconds against a 200-row test table can quietly become the slowest thing in your application once that table hits 2 million rows — with the SQL completely unchanged.</p>

<h3>Hash join — build once in memory, then probe</h3>

<p>This is the one your database usually prefers when tables get bigger, and it's a genuinely different strategy, not just an optimized version of nested loops:</p>

<pre><code>hash_table = {}
FOR each row s IN sales:                 -- BUILD phase: load smaller table into RAM as a hash table
    hash_table[s.employee_id] = s

FOR each row e IN employees:             -- PROBE phase: single pass, O(1) lookup per row
    IF e.employee_id IN hash_table:
        emit(e, hash_table[e.employee_id])</code></pre>

<p>Instead of re-scanning <code>sales</code> for every row of <code>employees</code>, the engine builds a hash table from the smaller table <em>once</em>, then makes a single pass through the larger table, doing a near-instant lookup for each row. That drops the cost to roughly <code>O(n + m)</code> — a massive improvement over nested loops.</p>

<p>Here's the detail that matters most, and the one that's easy to miss if you only think of this as "the fast one": <strong>hash joins are RAM-bound, not CPU-bound — until they aren't.</strong> The entire hash table has to fit in memory for this to work as described. If it doesn't — because the "smaller" table turns out to be 40GB — the engine doesn't just fail. It spills the hash table to disk in batches and keeps going, silently, with no error and no warning in your application. That spill is the moment the join flips from memory-bound to <strong>I/O-bound</strong> — from RAM lookups to disk reads and writes — and that flip is usually the actual difference between a query that takes one second and one that takes a full minute. Your query still returns the right answer. It just does it much slower, for a reason that has nothing to do with your SQL and everything to do with how much RAM was available at that moment.</p>

<p>This is why the same JOIN can be fast on your laptop and slow in production. It's not a different query. It's a different amount of memory to work with.</p>

<h3>Merge join — both sides pre-sorted, walked in lockstep</h3>

<p>The third option only works under a specific condition, but when that condition is met, it's elegant:</p>

<pre><code>-- Requires both inputs sorted on employee_id (via an index or an explicit sort):
e_ptr = 0
s_ptr = 0
WHILE e_ptr &lt; len(employees) AND s_ptr &lt; len(sales):
    IF employees[e_ptr].id == sales[s_ptr].id:
        emit(employees[e_ptr], sales[s_ptr])
        advance both pointers
    ELIF employees[e_ptr].id &lt; sales[s_ptr].id:
        e_ptr += 1
    ELSE:
        s_ptr += 1</code></pre>

<p>If both tables are already sorted on the join column, the engine can walk them side by side with two pointers, advancing whichever one is "behind," and never re-scanning anything. That's <code>O(n + m)</code> with none of the memory pressure of a hash join — no hash table to build, no risk of spilling to disk.</p>

<p>The catch is right there in the setup: <em>both sides have to already be sorted.</em> If they're not, the engine has to sort them first, and that sort cost can erase the entire advantage. Which is exactly what a well-placed index gives you for free — a structure that's <em>already</em> sorted, sitting in memory or on disk, ready for exactly this.</p>

<h2>Why this is the whole story, not a technicality</h2>

<p>Once you see JOINs this way, a few pieces of common database advice stop being folklore and start being obvious:</p>

<p><strong>"Just add an index" isn't magic — it's a memory-structure claim.</strong> An index is a B-tree, sitting in sorted order in memory or on disk. Adding one is what makes a merge join possible without paying a sort cost at query time. That's the actual mechanism behind advice you've probably followed without knowing why it worked.</p>

<p><strong>"It's fast on my machine but slow in production" is usually a hash-join-spilling-to-disk story.</strong> Your dev database has a small enough <code>sales</code> table that the hash table fits comfortably in RAM. Production's <code>sales</code> table is 100x larger. Same query, same plan the optimizer <em>wants</em> to use — but now it can't fit the hash table in memory, so it spills, and your "identical" query behaves completely differently under identical SQL.</p>

<p><strong>And this isn't unique to databases.</strong> It's the same <em>shape</em> of constraint you'll see anywhere a system has to work within bounded memory — worth being precise that it's the shape that repeats, not the exact mechanism. Google's Omni model caps video edits at around ten seconds — not because ten seconds is some meaningful creative limit, but because that's roughly what fits in its working context. The model's response to running out of context is truncation; a database's response to running out of RAM during a hash join is spilling to disk. Different fix, same root cause: a system quietly changing strategy the moment it outgrows the memory it was counting on — a cost you don't see until the moment it happens.</p>

<h2>The code, reframed</h2>

<p>Here's the exact query from the meme one more time — same SQL, but now the comments describe what's actually happening underneath instead of what the output looks like:</p>

<pre><code>SELECT
    e.employee_id,      -- probe key: this column drives the hash table lookup
    e.employee_name,
    e.department,
    s.net_sales
FROM employees e
JOIN sales s USING (employee_id);
-- If `sales` is small: engine likely builds a hash table from `sales` in memory,
--   then streams `employees` past it in one pass — O(n + m).
-- If `employee_id` is indexed on both sides: engine may prefer a merge join,
--   walking both sorted B-trees in lockstep — no hash table needed at all.
-- If statistics are stale or tables are tiny: engine may fall back to nested loop —
--   fine here, disastrous if either table grows without you noticing.</code></pre>

<p>You don't have to guess which one your database actually picked. You can just ask it:</p>

<pre><code>EXPLAIN ANALYZE
SELECT e.employee_id, e.employee_name, e.department, s.net_sales
FROM employees e
JOIN sales s USING (employee_id);</code></pre>

<p>Look for <code>Hash Join</code>, <code>Merge Join</code>, or <code>Nested Loop</code> in the output — that's your database telling you, in plain terms, which of the three strategies above it's actually running against your real data, right now. Here's what that actually looks like on a real (Postgres) run:</p>

<pre><code>Hash Join  (cost=1.23..458.67 rows=1000 width=64) (actual time=0.045..3.201 rows=1000 loops=1)
  Hash Cond: (e.employee_id = s.employee_id)
  -&gt;  Seq Scan on employees e  (cost=0.00..22.00 rows=1000 width=40)
  -&gt;  Hash  (cost=1.15..1.15 rows=6 width=24)
        -&gt;  Seq Scan on sales s  (cost=0.00..1.15 rows=6 width=24)
Planning Time: 0.112 ms
Execution Time: 3.245 ms</code></pre>

<p>Read it from the bottom up: the engine scanned <code>sales</code> (<code>Seq Scan on sales s</code>), built a <code>Hash</code> from it, then joined that hash against a scan of <code>employees</code> — exactly the BUILD-then-PROBE pattern from the pseudocode above. If this were spilling to disk, you'd see it flagged explicitly further down the plan (Postgres reports <code>Batches</code> greater than 1, which means the hash table didn't fit in the memory it was given and got split up) — that's your direct, no-guessing confirmation that the memory-bound-to-I/O-bound flip we just walked through is actually happening on your data.</p>

<h2>Where this leaves you</h2>

<p><code>ON</code> vs <code>USING</code> was never the real question. The real question is: what does your database know about your data — its size, its sort order, the memory it has to work with — and which of these three strategies does that knowledge point it toward?</p>

<p>Next time your query feels slow, skip the syntax tweaking. Run <code>EXPLAIN ANALYZE</code> and find out which of these three things is actually happening in memory. That's where the answer lives.</p>]]></content:encoded></item></channel></rss>