Your query might look something like this:
;with C as ( select A, B, C, D, row_number() over(partition by A, B order by C asc) as rn1, row_number() over(partition by A, B order by C desc) as rn2 from YourTable ) select C1.A, C1.B, C1.D as "D at MIN(C)", C2.D as "D at MAX(C)" from C as C1 inner join C as C2 on C1.A = C2.A and C1.B = C2.B where C1.rn1 = 1 and C2.rn2 = 1
The first part is a common table expression (CTE) that encapsulates a query that can be reused later in the main query. It uses row_number () to list the rows in each section. rn1 ordered on C asc , therefore rn1 = 1 for the minimum value of C and rn2 ordered on C desc , which means that rn2 = 1 for the maximum value of C The main query is that the CTE joins columns A and B twice. The where clause ensures that we only get strings where rn1 and rn2 are 1 .
Here is a working example that uses table @T instead of your table.
declare @T table ( A int, B int, C int, D int ) insert into @T values (1, 1, 1, 1), (1, 1, 3, 2), (1, 1, 0, 4), (1, 1, 2, 1), (1, 2, 1, 0), (1, 2, 0, 2), (1, 2, 4, 5), (2, 1, 5, 3) ;with C as ( select A, B, C, D, row_number() over(partition by A, B order by C asc) as rn1, row_number() over(partition by A, B order by C desc) as rn2 from @T ) select C1.A, C1.B, C1.D as "D at MIN(C)", C2.D as "D at MAX(C)" from C as C1 inner join C as C2 on C1.A = C2.A and C1.B = C2.B where C1.rn1 = 1 and C2.rn2 = 1