Short and sweet
GROUP BY DATEDIFF(MINUTE, '2000', date_column) / 10
With heavy acknowledgements to Derek's answer, which forms the core of this one.
Practical usage
SELECT DATEADD(MINUTE, DATEDIFF(MINUTE, '2000', aa.[date]) / 10 * 10, '2000')
AS [date_truncated],
COUNT(*) AS [records_in_interval],
AVG(aa.[value]) AS [average_value]
FROM [friib].[dbo].[archive_analog] AS aa
-- WHERE aa.[date] > '1900-01-01'
GROUP BY DATEDIFF(MINUTE, '2000', aa.[date]) / 10
-- HAVING SUM(aa.[value]) > 1000
ORDER BY [date_truncated]
Details and extra commentary
Bin interval size
The MINUTE
and 10
terms can be changed to any DATEPART
and integer,1 respectively, to group into different time intervals. For example, 10
with MINUTE
is ten minute intervals; 6
with HOUR
is six hour intervals.
If you change the interval a lot, you might benefit from declaring it as a variable.
DECLARE @interval int = 10;
SELECT DATEADD(MINUTE, DATEDIFF(…) / @interval * @interval, '2000')
…
GROUP BY DATEDIFF(…) / @interval
Value type
The actual values being grouped are a set of relative offsets from 2000-01-01 00:00
. This means data sources over long time intervals are fine. Some other answers have collision between years.
Multiplying the GROUP BY
expression by the interval size and wrapping it in a DATEADD
invocation will return you a DATETIME
value. Including it in the SELECT
statement will give your output a single column with the truncated timestamp. See the "Practical Usage" example above.
Shifting the label for the bin interval
The division (/
) operation after DATEDIFF
truncates values to integers (a FLOOR
shortcut), which yields the beginning of time intervals for each row in your SELECT
output.
If you want to label each row with the middle or end of its interval, you can tweak the division in the second term of DATEADD
with the bold part below:
- End of interval:
…) / 10 * 10
+ 10
, '2000')
, credit to Daniel Elkington.
- Middle of interval:
…) / 10 * 10
+ (10 / 2.0)
, '2000')
.
If you want to round your intervals inward such that each timestamp represents half an interval before and half an interval after it, use something like this:
DATEADD(MINUTE, ROUND(1. * DATEDIFF(MINUTE, '2000', date_column) / 10, 0) * 10, '2000')
Note the 1.
to do untruncated division instead. You will need to modify your GROUP BY
to match, and you may want to use the whole ROUND(…)
expression to avoid any unexpected float rounding.
Date math trivia
'2000'
is an "anchor date" around which SQL will perform the date math. Most sample code uses 0
for the anchor, but JereonH discovered that you encounter an integer overflow when grouping more-recent dates by seconds or milliseconds.2
If your data spans centuries,3 using a single anchor date in the GROUP BY
for seconds or milliseconds will still encounter the overflow. For those queries, you can ask each row to anchor the binning comparison to its own date's midnight.
Use one of the two replacements instead of '2000'
wherever it appears in the query:
DATEADD(DAY, DATEDIFF(DAY, 0, aa.[date]), 0)
CONVERT(DATETIME, CONVERT(DATE, aa.[date]))
Your query will be totally unreadable, but it will work.
1 If you want all :00
timestamps to be eligible for binning, use an integer that your DATEPART
's maximum can evenly divide into.4 As a counterexample, grouping results into 13-minute or 37-hour bins will skip some :00
s, but it should still work fine.
2 The math says 232 ≈ 4.29E+9. This means for a DATEPART
of SECOND
, you get 4.3 billion seconds on either side, which works out to "anchor date ± 136 years." Similarly, 232 milliseconds is ≈ 49.7 days.
3 If your data actually spans centuries or millenia and is still accurate to the second or millisecond… congratulations! Whatever you're doing, keep doing it.
4 If you ever wondered why our clocks have a 12 at the top, reflect on how 5 is the only integer from 6 (half of 12) or below that is not a factor of 12. Then note that 5 × 12 = 60. You have lots of choices for bin sizes with hours, minutes, and seconds.