Streamlining your tech stack for maximum efficiency

Learn, explore, and grow with our knowledge hub.

SQL Latest Record per Customer | Smart Statistics

Smart Statistics · SQL practical tutorial

SQL Latest Record per Customer: Build a Reliable Contact Summary

An account manager needs a contact summary: the latest conversation, its outcome and the team responsible. A maximum date alone cannot supply that complete record. A careless query can attach the wrong owner, return two rows or bring an old follow-up back to life.

This SQL Server tutorial builds a repeatable selection rule, keeps customers with no recorded contact visible and shows exactly where to apply an outcome filter. You can run the full example without changing permanent tables.

Prerequisites: access to a SQL Server or Azure SQL Database query window, and basic SELECT knowledge. All sample timestamps are treated as UTC.

Illustrative example · Contact coverage

A complete customer list, with one selected contact each

Customers4Illustrative example
With a recorded contact3Illustrative example
Latest outcome: follow-up1Illustrative example
Illustrative example · Coverage reconciled
Sources: customer register and contact history. Selection: newest contact time, then highest contact ID for ties. Workflow: rank → select → retain all customers → filter the summary.

Define what “latest” means before writing SQL

For this example, latest means the greatest ContactAt value for each customer. If two contacts have the same timestamp, the higher ContactID wins. This is an explicit demonstration rule, not a claim that a larger ID always means a later business event.

Choose the grouping key

Use the stable customer ID. If IDs repeat across companies, partition and join by the full company-and-customer key. A name is not a reliable identity.

Choose the business clock

Contact time and import time answer different questions. A late-arriving record of an older conversation must not replace a more recent conversation simply because it was loaded last.

Resolve equal timestamps

Agree a unique tie-breaker. Microsoft notes that repeatable row ordering requires uniqueness in the relevant partition and ordering values. [1] Deterministic selection still needs a sensible business rule.

Use this pattern when the output needs one complete row per customer. If every equally recent record must remain visible, a single-winner rule is the wrong requirement. If the question is historical, define the reporting cutoff before ranking.

Run the complete worked example

Open a new query window connected to your test database. Paste the whole batch below and execute it together. The table variables exist only within the batch. Do not insert GO between the declarations and queries.

The data deliberately includes an old follow-up followed by a resolution, equal contact timestamps, a late-loaded older contact and a customer with no contact. Those are the cases that a clean-looking sample often misses.

-- Illustrative example. Run the whole batch in SQL Server or Azure SQL Database.
-- Table variables only: no permanent business tables are changed.
DECLARE @Customers TABLE (
    CustomerID int PRIMARY KEY,
    CustomerName nvarchar(60) NOT NULL
);
DECLARE @Contacts TABLE (
    ContactID int PRIMARY KEY,
    CustomerID int NOT NULL,
    ContactAt datetime2(0) NOT NULL,
    Outcome nvarchar(30) NOT NULL,
    ContactOwner nvarchar(30) NOT NULL
);
DECLARE @Summary TABLE (
    CustomerID int PRIMARY KEY,
    CustomerName nvarchar(60) NOT NULL,
    ContactID int NULL,
    ContactAt datetime2(0) NULL,
    Outcome nvarchar(30) NULL,
    ContactOwner nvarchar(30) NULL
);

INSERT INTO @Customers VALUES
(101, N'Example Trade'),
(102, N'Example Services'),
(103, N'Example Retail'),
(104, N'Example Logistics');

INSERT INTO @Contacts VALUES
(1, 101, '2026-11-02T09:00:00', N'Follow-up', N'Team A'),
(2, 101, '2026-11-05T11:00:00', N'Resolved',  N'Team B'),
(3, 102, '2026-11-04T10:00:00', N'Follow-up', N'Team A'),
(4, 102, '2026-11-04T10:00:00', N'Resolved',  N'Team C'),
(5, 103, '2026-11-06T14:00:00', N'Follow-up', N'Team B'),
(6, 101, '2026-11-01T08:00:00', N'Follow-up', N'Team C');

;WITH RankedContacts AS (
    SELECT
        ContactID,
        CustomerID,
        ContactAt,
        Outcome,
        ContactOwner,
        ROW_NUMBER() OVER (
            PARTITION BY CustomerID
            ORDER BY ContactAt DESC, ContactID DESC
        ) AS rn
    FROM @Contacts
)
INSERT INTO @Summary (
    CustomerID, CustomerName, ContactID,
    ContactAt, Outcome, ContactOwner
)
SELECT
    c.CustomerID,
    c.CustomerName,
    r.ContactID,
    r.ContactAt,
    r.Outcome,
    r.ContactOwner
FROM @Customers AS c
LEFT JOIN RankedContacts AS r
    ON r.CustomerID = c.CustomerID
   AND r.rn = 1;

-- A: One row for every customer, including those without contacts.
SELECT * FROM @Summary ORDER BY CustomerID;

-- B: Follow-up only when the latest contact says Follow-up.
SELECT * FROM @Summary
WHERE Outcome = N'Follow-up'
ORDER BY CustomerID;

-- C: Coverage checks: expect 4, 4, 3, 1.
SELECT
    (SELECT COUNT(*) FROM @Customers) AS Customers,
    COUNT(*) AS SummaryRows,
    COUNT(ContactID) AS CustomersWithContact,
    SUM(CASE WHEN ContactID IS NULL THEN 1 ELSE 0 END) AS NoContact
FROM @Summary;

-- D: Orphan contacts: expect no rows.
SELECT e.ContactID, e.CustomerID
FROM @Contacts AS e
LEFT JOIN @Customers AS c ON c.CustomerID = e.CustomerID
WHERE c.CustomerID IS NULL;

Read the selection in four parts

  1. PARTITION BY CustomerID starts a separate ranking for each customer.
  2. ORDER BY ContactAt DESC, ContactID DESC puts the newest timestamp first and settles a tie with the unique ID.
  3. r.rn = 1 in the join attaches the chosen contact while preserving customers with no match. Putting this condition in the final WHERE clause would remove those unmatched customers.
  4. Filter the stored summary when asking which customers’ latest contact needs follow-up. Do not remove resolved contacts before deciding which record is latest.

The common table expression makes the ranking readable; it is not a saved table or a cache. Its scope is the following statement. Here, the selected rows are inserted into @Summary so subsequent checks use that same result. [2]

Compare the output with these answers

Illustrative example — result A
CustomerContact IDOutcomeOwner
101 · Example Trade2ResolvedTeam B
102 · Example Services4ResolvedTeam C
103 · Example Retail5Follow-upTeam B
104 · Example LogisticsNULLNULLNULL

Result B contains customer 103 only. Result C returns 4 customers, 4 summary rows, 3 with contacts and 1 without. Result D returns no orphan contacts. The summary primary key also prevents more than one output row for a customer.

Contact 6 does not win for customer 101: its timestamp is older, even though its ID is larger. Customer 102 selects contact 4 because the timestamp ties with contact 3. Customer 104 remains visible, with null contact fields rather than an invented date or a misleading “Resolved” label.

Keep columns together. Independently taking MAX of the date, owner and outcome can construct a combination that never existed. Rank rows and return the fields from the winning row.

Test the question, not just the row count

QuestionFilter positionMeaning
Whose latest contact needs follow-up?After ranking and selecting the latest row.A later resolution removes the customer from the follow-up summary.
What was each customer’s most recent follow-up contact?Before ranking, restrict candidates to Follow-up.This is a different report and can include a customer whose later contact was resolved.
What was latest at an agreed reporting cutoff?Restrict eligible contact times before ranking.Later contacts cannot overwrite the as-at answer.

Add test cases in a copy of the batch: a second customer with no history, another tied timestamp and an older record imported after a newer one. Check the selected IDs as well as totals. A report can have the right number of rows and still select the wrong records.

For an as-at report, decide whether you mean “events that occurred by then” or “information known to us by then”. The latter also needs reliable recorded-at history or snapshots. The sample is a current summary over the supplied data; it does not reconstruct what a past report actually showed.

Turn the query into a maintained reporting asset

Own the meaning

The account-management owner approves the contact definition, tie rule and follow-up interpretation. A contact outcome is not automatically the status of every open task for that customer. Keep a separate task register when actions can remain open across conversations.

Own the source quality

The data owner investigates missing dates, orphan IDs and duplicate event identifiers. The sample rejects null contact dates. In production, report undated contacts as exceptions; do not silently describe them as “no history”.

Own performance

The SQL maintainer can expose the approved selection through a reporting view. Review execution plans and data volumes before adding an index aligned with the customer key and descending selection columns. A CTE alone is not a performance optimisation.

Own adoption

Label the output “Latest recorded contact” and show its timestamp. Keep “No recorded contact” distinct from an empty filtered result. Confirm refresh timing and access before using the summary in Power BI or an account-review meeting.

Pilot with a small account-management team. Measure incorrect follow-ups caused by stale records, customers omitted because they lack history and time spent checking the source. Compare the same review task before and after adoption; do not assume a financial saving from fewer rows alone.

Check your latest-record logic

This local checklist sends nothing and stores no responses. It checks implementation evidence, not whether every customer record is accurate.

Tick only checks you have demonstrated
0 of 5 0 of 5 checks evidenced. Confirm selection order and filter meaning before release.

Frequently asked questions

Why is MAX(ContactAt) not enough?

It returns the latest timestamp, not the complete contact row. More than one contact can share that timestamp, and unrelated MAX expressions can select values from different rows.

Does the largest contact ID always mean the latest contact?

No. This tutorial ranks by contact time first and uses the ID only to settle equal timestamps. A larger ID can belong to an older contact entered later.

Why does the latest-row condition belong in the join?

With a left join, keeping rn = 1 in the join condition preserves customers without a matching contact. Applying that condition in the final WHERE clause removes their null matches.

Does this query delete older contacts?

No. It selects a reporting summary. The sample uses table variables and does not update or delete permanent business records.

Make your reporting rules dependable

Smart Statistics can help turn SQL history into clear, tested reporting tables and Power BI models, with agreed definitions and visible exceptions.

Sources and scope

Microsoft documentation checked on 11 November 2026. All customers, contacts and dashboard figures are illustrative. This batch targets SQL Server and Azure SQL Database; other SQL engines may need syntax changes.

  1. Microsoft Learn: ROW_NUMBER — partitioning, ordering and ties.
  2. Microsoft Learn: common table expressions — statement scope and materialisation.