Ben Nour

Using PostgreSQL to calculate how often and how many days a store's inventory is out of stock

Recently a Reddit user posted in r/SQL wanting to know how to calculate how often and how many days an item was out of stock.

I enjoyed coming up with a solution and so I've decided to explore my answer in more detail on this website.

Note that I'll be using PostgreSQL.

The data

First, the dataset we'll be operating on:

item transaction_date inventory_level
Bicycle 2024-01-01 0
Bicycle 2024-01-05 0
Bicycle 2024-01-10 10
Bicycle 2024-01-15 5
Bicycle 2024-01-16 0
Bicycle 2024-01-20 0
Bicycle 2024-01-23 0
Bicycle 2024-01-25 0
Bicycle 2024-01-28 20
Bicycle 2024-01-29 12
Bicycle 2024-01-31 0

The original poster explained that a record is created in the database (1st Jan 2024) before inventory is recieved for an item.

The other records in the dataset are when a count of inventory is conducted.

It's worth noting that each row in our dataset is when an inventory check was made so it is possible that bicycles could have come in and out of stock between inventory checks. However that's a data entry problem, not ours. We can only work with the data we've been given!

If you want to follow along in Postgres here is the code to create the table and insert the data:

CREATE TABLE stock (
    item VARCHAR(50),
    transaction_date DATE,
    inventory_level INT
);

INSERT INTO stock (item, transaction_date, inventory_level) VALUES
('Bicycle', '2024-01-01', 0),
('Bicycle', '2024-01-05', 0),
('Bicycle', '2024-01-10', 10),
('Bicycle', '2024-01-15', 5),
('Bicycle', '2024-01-16', 0),
('Bicycle', '2024-01-20', 0),
('Bicycle', '2024-01-23', 0),
('Bicycle', '2024-01-25', 0),
('Bicycle', '2024-01-28', 20),
('Bicycle', '2024-01-29', 12),
('Bicycle', '2024-01-31', 0)
;

The challenge

We want to find out how often our store was out of stock of bicycles and for how may days.

Counting from the right start date

We only want to start counting from when inventory is first recieved, NOT when the item is first entered into the system or when an inventory count is conducted before stock has ever been recieved.

We can do this by using a correlated subquery:

SELECT A.*
FROM STOCK AS A 
WHERE A.TRANSACTION_DATE >= 
    (
    SELECT MIN(B.TRANSACTION_DATE)
    FROM STOCK AS B
    WHERE B.ITEM = A.ITEM 
    AND B.INVENTORY_LEVEL != 0
    )

Result set:

item transaction_date inventory_level
Bicycle 2024-01-10 10
Bicycle 2024-01-15 5
Bicycle 2024-01-16 0
Bicycle 2024-01-20 0
Bicycle 2024-01-23 0
Bicycle 2024-01-25 0
Bicycle 2024-01-28 20
Bicycle 2024-01-29 12
Bicycle 2024-01-31 0

Calculating how many times bicycles were sold out

Next, let's identify when the bicycle goes out of stock.

How do we do this?

It's simple enough to assert by verifying how much inventory is on hand:

CASE WHEN INVENTORY_LEVEL = 0 THEN 'OUT OF STOCK' ELSE 'IN STOCK' END

However we want to calculate how often an item goes out of stock so to do this what we'll want to do is identify when an item goes out of stock after having previously had stock.

In other words, we want to capture every instance in which on the last inventory count day there was stock and on the current counting day there was no stock.

We can achieve this using the LAG() window function to see how much inventory there was on the previous transcation date:

SELECT
A.*
, CASE 
    WHEN A.INVENTORY_LEVEL = 0 -- No stock!
    and LAG(INVENTORY_LEVEL, 1) OVER (PARTITION BY ITEM ORDER BY TRANSACTION_DATE) != 0 -- The previous day there WAS stock.
    THEN 'OUT_OF_STOCK' 
    WHEN A.INVENTORY_LEVEL != 0 THEN 'IN STOCK'
END AS STOCK_STATUS
FROM STOCK AS A 
WHERE A.TRANSACTION_DATE >= 
    (
    SELECT MIN(B.TRANSACTION_DATE)
    FROM STOCK AS B
    WHERE B.ITEM = A.ITEM 
    AND B.INVENTORY_LEVEL != 0
    )

The Result set:

item transaction_date inventory_level stock_status
Bicycle 2024-01-10 10 IN STOCK
Bicycle 2024-01-15 5 IN STOCK
Bicycle 2024-01-16 0 OUT_OF_STOCK
Bicycle 2024-01-20 0 [NULL]
Bicycle 2024-01-23 0 [NULL]
Bicycle 2024-01-25 0 [NULL]
Bicycle 2024-01-28 20 IN STOCK
Bicycle 2024-01-29 12 IN STOCK
Bicycle 2024-01-31 0 OUT_OF_STOCK

Perfect! We can see that twice bicycles have gone out of stock.

Calculating how long bicycles were sold out

Next, let's calculate how long bicycles were sold out for.

All we need to do is calculate the difference in days between when an item was flagged as having gone out of stock and when it next came into stock (or more accurately, when an inventory check was recorded that revealed there was stock).

This is simple enough using the LEAD() window function, which we can use to return the next transcation date.

As we've filtered out rows with NULL for the stock_status column, what will be returned is, if it exists, the next transcation date in which an inventory count is conducted that reveals bicycles are back in stock.

I've also wrapped COALESCE() around LEAD() so if there is no next row (meaning bicycles are still out of stock) what's returned is the current date so the date calculation will work:

SELECT 
*
, COALESCE(LAG(TRANSACTION_DATE,1) OVER (PARTITION BY ITEM ORDER BY TRANSACTION_DATE DESC), CURRENT_DATE)
- TRANSACTION_DATE AS DAYS_OUT_OF_STOCK
FROM 
(
    SELECT
    A.*
    , CASE 
        WHEN A.INVENTORY_LEVEL = 0 
        AND LAG(INVENTORY_LEVEL, 1) OVER (PARTITION BY ITEM ORDER BY TRANSACTION_DATE) != 0 
        THEN 'OUT_OF_STOCK' 
        WHEN A.INVENTORY_LEVEL != 0 THEN 'IN STOCK'
    END AS STOCK_STATUS
    FROM STOCK AS A 
    WHERE 
    A.TRANSACTION_DATE >= 
        (
        SELECT MIN(B.TRANSACTION_DATE)
        FROM STOCK AS B
        WHERE B.ITEM = A.ITEM 
        AND B.INVENTORY_LEVEL != 0
        )
) C
WHERE STOCK_STATUS IS NOT NULL
ORDER BY 2

Result set:

item transaction_date inventory_level stock_status days_out_of_stock
Bicycle 2024-01-10 10 IN STOCK 5
Bicycle 2024-01-15 5 IN STOCK 1
Bicycle 2024-01-16 0 OUT_OF_STOCK 12
Bicycle 2024-01-28 20 IN STOCK 1
Bicycle 2024-01-29 12 IN STOCK 2
Bicycle 2024-01-31 0 OUT_OF_STOCK 153

Perfect!

We have our answer.

The Redditor who I helped asked for a single row per item which broke out how many times the item was out of stock and for how long (in a comma-seperated string) so let's do that:

SELECT   
ITEM
, COUNT(STOCK_STATUS) AS TIMES_OUT_OF_STOCK
, STRING_AGG(DAYS_OUT_OF_STOCK::VARCHAR, ',') AS DAYS_OUT_OF_STOCK
FROM 
    (
    SELECT 
    *
    , COALESCE(LAG(TRANSACTION_DATE,1) OVER (PARTITION BY ITEM ORDER BY TRANSACTION_DATE DESC), CURRENT_DATE)
    - TRANSACTION_DATE AS DAYS_OUT_OF_STOCK
    FROM 
    (
        SELECT
        A.*
        , CASE 
            WHEN A.INVENTORY_LEVEL = 0 
            AND LAG(INVENTORY_LEVEL, 1) OVER (PARTITION BY ITEM ORDER BY TRANSACTION_DATE) != 0 
            THEN 'OUT_OF_STOCK' 
            WHEN A.INVENTORY_LEVEL != 0 THEN 'IN STOCK'
        END AS STOCK_STATUS
        FROM STOCK AS A 
        WHERE 
        A.TRANSACTION_DATE >= 
            (
            SELECT MIN(B.TRANSACTION_DATE)
            FROM STOCK AS B
            WHERE B.ITEM = A.ITEM 
            AND B.INVENTORY_LEVEL != 0
            )
    ) C
    WHERE STOCK_STATUS IS NOT NULL
    ORDER BY 2
    ) AS D
WHERE STOCK_STATUS = 'OUT_OF_STOCK'
GROUP BY 1

Our final result set:

item times_out_of_stock days_out_of_stock
Bicycle 2 13,153

You can also test the code used in this post here.

Comments !