Why does POSIXlt behavior differ in R when its S3 class is lost inside of vs. outside of a data.frame?
14:34 25 Jan 2026

I am learning about S3, and trying to understand behaviors that happen when objects lose their S3 class because they are assigned into a vector that is not of that class.

Essentially, this behavior, which is also what underlies ifelse() stripping classes and other attributes:

########## Test 1: Assignment from a vector with an S3 class to another vector loses the class ##########

make_classA <- function(vals) {
  output <- structure(as.character(vals),
                      other_attrib=round(sqrt(abs(vals))), # Just for demonstration/testing
                      class='classA')
  return(output)
}

# Make it so subsetting classA keeps its attributes
`[.classA` <- function(x, i, ...) {
  out <- unclass(x)[i]     # subset the underlying vector
  structure(
    out,
    other_attrib = attr(x, "other_attrib")[i],
    class = "classA"
  )
}

s3dat <- make_classA(c(4,9,16,25))

# Subsetting keeps attributes
attributes(s3dat[2:3])

# Make a vector to assign into that's just R's basic numeric type
basic <- c(1.3,2.4,3.5,4.6)

# If I assign a value from a classA vector to a numeric vector, the numeric vector is converted to character (not classA) and class/attributes are lost
basic[2] <- s3dat[2]

class(basic)
# [1] "character"
attributes(basic)
# NULL

I understand that the above is an expected and normal (if unfortunate) aspect of how R works, and I thought it was unavoidable.

From what I thought I understood, if "basic" is a vector or data.frame column containing data as one of the basic R types, and "s3dat" is a vector or data.frame column containing data as an S3 class:

  • You can define assignment methods for your S3 class to control the behavior of s3dat[2] <- basic[2]

  • You cannot do anything about the reverse scenario, basic[2] <- s3dat[2], that is entirely beyond your control. (But now I think I might be wrong about this...)

But then I noticed that POSIXlt (which is an S3 object) manages to produce different behaviors in the basic[2] <- s3dat[2] scenario I thought was beyond the class creator's control:

  • When the assignment is made between two vectors (Test 2 below), the vector the POSIXlt is assigned into is converted to a list, and the value "0" is assigned in the position where the POSIXlt would have been assigned.

  • When the assignment is made between two columns in a data.frame (Test 3 below), the POSIXlt is converted as if as.numeric() had been called on it.

########## Test 2: Assign an item from POSIXlt vector to a numeric vector ##########

library('sloop') # Provides the s3_dispatch() function

# Make a POSIXlt vector and a numeric vector
s3dat <- as.POSIXlt(c('2025-01-01 10:00:00','2025-01-02 10:00:00','2025-01-03 9:57:00'))
basic <- c(1.3,2.4,3.5)

# What methods would basic[2] <- s3dat[2] use?
s3_dispatch(basic[2] <- s3dat[2])
  #  <-.double
  #  <-.numeric
  #  <-.default

# What happens when I try it?
basic[2] <- s3dat[2]
# Warning message:
# In basic[2] <- s3dat[2] :
#   number of items to replace is not a multiple of replacement length

# The result: basic has become a list, and basic[[2]] is 0
basic
# [[1]]
# [1] 1.3
# 
# [[2]]
# [1] 0
# 
# [[3]]
# [1] 3.5
########## Test 3: Assign a POSIXlt in a numeric column a data.frame ##########

library('sloop') # Provides the s3_dispatch() function

# Make a data.frame with a POSIXlt column and a numeric column
df <- data.frame(s3dat=as.POSIXlt(c('2025-01-01 10:00:00','2025-01-02 10:00:00','2025-01-03 9:57:00')),basic=c(1.3,2.4,3.5))

# What methods would df$basic[2] <- df$s3dat[2] use?
s3_dispatch(df$basic[2] <- df$s3dat[2])
  #  <-.double
  #  <-.numeric
  #  <-.default

# What happens when I try it?
df$basic[2] <- df$s3dat[2]
# No warning message

# The result: df$basic has remained numeric, and the value assigned was as.numeric(df$s3dat[2])
#                 s3dat       basic
# 1 2025-01-01 10:00:00 1.30000e+00
# 2 2025-01-02 10:00:00 1.73583e+09
# 3 2025-01-03 09:57:00 3.50000e+00

as.numeric(df$s3dat[2])==df$basic[2] # TRUE

# Not shown for length: The behavior of df[2,'basic'] <- df[2,'s3dat'] is the same as in this test

How does POSIXlt do this?

I can kind of understand the behavior with vectors in Test 2, since the POSIXlt contains a list, so R converting the vector "basic" to a list might just be another case of the known behavior shown in Test 1. But what's going on with the data.frame in Test 3?

I explored the data.frame thing a little further, and found the difference seems to be whether the input column comes from a data.frame:

  • df$basic[2] <- s3dat[2] where the assigned value comes from a vector produces the same behavior as Test 2 a warning about length differences, and and df$basic[2] is turned into a list.

  • basic[2] <- df$s3dat[2] where the assigned value comes from a data.frame column produces the same behavior as Test 3 - no warning, the POSIXlt is converted as if as.numeric() was called on it, and "basic" remains a numeric vector

What's happening to make the behaviors different with a data.frame?

And are there any other options for altering the behavior of basic[2] <- s3dat[2] or df$basic[2] <- db$s3dat[2] where "basic" contains data as one of the basic R types and "s3dat" contains data as an S3 class?

r implicit-conversion r-s3