Many years ago, I was building a network topology dashboard. We had chassis with line cards that followed a naming convention: au-10-1 where the first number is the chassis slot and the second is the port.
The product manager asked for a simple list of line cards sorted logically. Just ORDER BY line_card_id and call it a day.
The output looked like this:
au-1-1
au-10-1
au-11-1
au-2-1
"Why is slot 10 showing up before slot 2?" he asked.
Because SQL sorts strings alphabetically. The character '1' comes before '2', so '10' sorts before '2'. Computers are literal like that.
This is the natural sort problem. Humans expect numbers embedded in strings to sort numerically. Databases sort character by character.
Let's fix it.
You're managing network equipment inventory. Line cards have IDs like au-10-1 where:
au is the card type (not important for sorting)10 is the chassis slot number1 is the port number within that slotEach device might have multiple line cards across different slots:
DROP TABLE IF EXISTS network_topology CASCADE;
CREATE TABLE network_topology (
id INT PRIMARY KEY,
device_id INT,
ip_address VARCHAR(15),
line_card_id VARCHAR(20)
);
INSERT INTO network_topology VALUES
(1, 10, '10.10.0.1', 'au-10-1'),
(2, 10, '10.10.0.2', 'au-10-2'),
(3, 10, '10.10.1.1', 'au-11-1'),
(4, 10, '10.10.1.2', 'au-11-2'),
(5, 10, '10.10.0.1', 'au-1-1'),
(6, 10, '10.10.0.1', 'au-1-2'),
(7, 10, '10.10.2.1', 'au-2-1'),
(8, 10, '10.10.2.2', 'au-2-2');
You need: Line cards sorted by chassis slot (1, 2, 10, 11), then by port within each slot.
Alphabetical ORDER BY gives you: au-1-1, au-1-2, au-10-1, au-10-2, au-11-1, au-11-2, au-2-1, au-2-2
What you actually want: au-1-1, au-1-2, au-2-1, au-2-2, au-10-1, au-10-2, au-11-1, au-11-2
Let's fix it.
The trick is to pull out the numeric parts and sort them as actual numbers, not strings.
SELECT
id,
device_id,
ip_address,
line_card_id
FROM network_topology
ORDER BY
SPLIT_PART(line_card_id, '-', 2)::INTEGER,
SPLIT_PART(line_card_id, '-', 3)::INTEGER;
Output:
id | device_id | ip_address | line_card_id
---|-----------|------------|-------------
5 | 10 | 10.10.0.1 | au-1-1
6 | 10 | 10.10.0.1 | au-1-2
7 | 10 | 10.10.2.1 | au-2-1
8 | 10 | 10.10.2.2 | au-2-2
1 | 10 | 10.10.0.1 | au-10-1
2 | 10 | 10.10.0.2 | au-10-2
3 | 10 | 10.10.1.1 | au-11-1
4 | 10 | 10.10.1.2 | au-11-2
Perfect. Slot 1, then slot 2, then slot 10, then slot 11. Within each slot, ports are in numeric order.
Let's unpack that ORDER BY clause.
First sort key (chassis slot):
SPLIT_PART(line_card_id, '-', 2)::INTEGER
How it works:
SPLIT_PART(line_card_id, '-', 2) splits by dash and returns the 2nd part: '10'::INTEGER casts the string to an integer: 10For au-1-2: SPLIT_PART returns '1', cast gives 1.
For au-10-1: SPLIT_PART returns '10', cast gives 10.
Second sort key (port number):
SPLIT_PART(line_card_id, '-', 3)::INTEGER
Same idea - grab the 3rd part (after the second dash) and cast to integer.
For au-10-2: Returns '2', cast gives 2.
The solution above uses PostgreSQL. Other databases have different string functions.
MySQL / MariaDB:
ORDER BY
CAST(SUBSTRING_INDEX(SUBSTRING_INDEX(line_card_id, '-', 2), '-', -1) AS UNSIGNED),
CAST(SUBSTRING_INDEX(line_card_id, '-', -1) AS UNSIGNED);
SUBSTRING_INDEX(str, '-', 2) gets everything up to the 2nd dash. The nested call with -1 grabs everything after the last dash of that result.
Oracle:
ORDER BY
TO_NUMBER(REGEXP_SUBSTR(line_card_id, '\d+', 1, 2)),
TO_NUMBER(REGEXP_SUBSTR(line_card_id, '\d+', 1, 3));
Oracle's REGEXP_SUBSTR with occurrence parameter is powerful. The third argument (1) is start position, fourth argument (2 or 3) is which match to return.
SQL Server:
ORDER BY
CAST(PARSENAME(REPLACE(line_card_id, '-', '.'), 2) AS INT),
CAST(PARSENAME(REPLACE(line_card_id, '-', '.'), 1) AS INT);
SQL Server doesn't have SPLIT_PART, but PARSENAME works on dot-delimited strings. So we replace dashes with dots first.
If you need this sort order frequently, you can create a computed column that pads the numbers with zeros:
SELECT
id,
line_card_id,
SPLIT_PART(line_card_id, '-', 1) || '-' ||
LPAD(SPLIT_PART(line_card_id, '-', 2), 3, '0') || '-' ||
LPAD(SPLIT_PART(line_card_id, '-', 3), 3, '0') AS sort_key
FROM network_topology
ORDER BY sort_key;
Output:
line_card_id | sort_key
-------------|----------
au-1-1 | au-001-001
au-1-2 | au-001-002
au-2-1 | au-002-001
au-2-2 | au-002-002
au-10-1 | au-010-001
au-10-2 | au-010-002
Now alphabetical sorting works because au-001-001 comes before au-010-001.
LPAD(value, 3, '0') pads the value to 3 digits with leading zeros. So '1' becomes '001' and '10' becomes '010'.
When to use this:
When extraction is fine:
Goal: Sort by chassis slot first, then card type alphabetically, then port number.
Sample data:
au-1-1
au-1-2
bc-1-1
bc-1-2
au-2-1
bc-2-1
Expected output:
au-1-1
bc-1-1
au-1-2
bc-1-2
au-2-1
bc-2-1
Hints:
SPLIT_PART(line_card_id, '-', 1)Pseudocode:
ORDER BY
extract_chassis_slot AS INTEGER,
extract_card_type AS VARCHAR,
extract_port AS INTEGER
Goal: Find missing port numbers in each chassis slot. If chassis 10 has ports 1, 2, and 5, ports 3 and 4 are gaps.
Expected output:
chassis_slot | missing_port
-------------|-------------
10 | 3
10 | 4
Hints:
Pseudocode:
WITH port_range AS (
-- Get min/max port per chassis
),
all_ports AS (
-- Generate series from min to max
)
SELECT chassis_slot, port_num AS missing_port
FROM all_ports
LEFT JOIN actual_ports ...
WHERE actual_port IS NULL
PostgreSQL has GENERATE_SERIES. MySQL needs a recursive CTE or numbers table.
I've used this pattern for:
Anywhere you embed numbers in identifiers, you'll need this.
When you have identifiers with embedded numbers:
ORDER BY
CAST(extract_first_number AS INTEGER),
CAST(extract_second_number AS INTEGER),
...
Different databases, different syntax. Same approach.
For large tables (100k+ rows):
ALTER TABLE network_topology
ADD COLUMN sort_key VARCHAR(30)
GENERATED ALWAYS AS (
LPAD(SPLIT_PART(line_card_id, '-', 2), 3, '0') || '-' ||
LPAD(SPLIT_PART(line_card_id, '-', 3), 3, '0')
) STORED;
CREATE INDEX idx_sort_key ON network_topology(sort_key);
Now ORDER BY sort_key uses the index instead of extracting and casting for every row.
INSERT INTO network_topology (device_id, ip_address, line_card_id, chassis_slot, port)
VALUES (10, '10.10.0.1', 'au-10-1', 10, 1);
Store the numeric parts separately. Then sorting is trivial.
CREATE MATERIALIZED VIEW sorted_topology AS
SELECT * FROM network_topology
ORDER BY
SPLIT_PART(line_card_id, '-', 2)::INTEGER,
SPLIT_PART(line_card_id, '-', 3)::INTEGER;
Refresh periodically. Queries hit the materialized view instead of recomputing the sort.
Head to the SQL editor and try these on your own database.
Challenges:
au-10-1-A) and sort by all partsAdvanced:
au-10 without a port)Tags: #SQL #StringFunctions #Sorting #NetworkTopology #NaturalSort
Difficulty: Intermediate
Topics: String Manipulation, Type Casting, Sorting, Real-World Data
SQL Output